feat(share): node share (uri/qr/client_yaml) + Access Profile in URIs

This commit is contained in:
風雪 committed 2026-08-29 00:10:35 +08:00
1 parent bb875405b3
commit dc62de00a3
3 files changed
+3 -1502

No files matched your search

+1 -715
View File
@@ -1,715 +1 @@
package converter
import (
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"github.com/kazeyukiro/3m-ui/backend/internal/config"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"github.com/kazeyukiro/3m-ui/backend/internal/netutil"
"github.com/kazeyukiro/3m-ui/backend/internal/user"
"golang.org/x/crypto/curve25519"
"gopkg.in/yaml.v3"
"gorm.io/gorm"
)
func ResolveServerAddress(cfg *config.Config, req *http.Request) string {
if envURL := os.Getenv("PUBLIC_URL"); envURL != "" {
return cleanURLHost(envURL)
}
if cfg != nil && cfg.Server.PublicURL != "" {
return cleanURLHost(cfg.Server.PublicURL)
}
if req != nil && req.Host != "" {
return cleanURLHost(req.Host)
}
return "127.0.0.1"
}
// ResolveListenerServer prefers per-listener PublicHost (IPv4/IPv6/domain),
// then the global public URL / request host. Used for share links and client YAML.
func ResolveListenerServer(cfg *config.Config, req *http.Request, l models.Listener) string {
if h := netutil.NormalizeHost(l.PublicHost); h != "" && !netutil.IsUnspecifiedBind(h) {
return h
}
return ResolveServerAddress(cfg, req)
}
// ResolveListenerPort prefers PublicPort when set (NAT / dual-stack publish).
func ResolveListenerPort(l models.Listener) string {
if p := strings.TrimSpace(l.PublicPort); p != "" {
return p
}
return strings.TrimSpace(l.Port)
}
func cleanURLHost(raw string) string {
u := netutil.NormalizeHost(raw)
if u == "" || strings.ContainsAny(u, "\r\n/\\") {
return "127.0.0.1"
}
return u
}
func GetSubscriptionURL(cfg *config.Config, req *http.Request, token string, target string) string {
var base string
if envURL := os.Getenv("PUBLIC_URL"); envURL != "" {
base = strings.TrimSpace(envURL)
} else if cfg != nil && cfg.Server.PublicURL != "" {
base = strings.TrimSpace(cfg.Server.PublicURL)
} else if req != nil && req.Host != "" {
scheme := "http"
if req.TLS != nil {
scheme = "https"
} else if proto := req.Header.Get("X-Forwarded-Proto"); strings.EqualFold(proto, "https") {
scheme = "https"
}
// req.Host already includes [ipv6]:port when applicable.
base = fmt.Sprintf("%s://%s", scheme, req.Host)
} else {
base = "http://127.0.0.1:8080"
}
base = strings.TrimSuffix(base, "/")
pathToken := url.PathEscape(token)
if target == "" {
return fmt.Sprintf("%s/api/v1/client/sub/%s", base, pathToken)
}
return fmt.Sprintf("%s/api/v1/client/sub/%s?target=%s", base, pathToken, url.QueryEscape(strings.ToLower(strings.TrimSpace(target))))
}
func GenerateRawConfig(db *gorm.DB, token models.AccessToken, req *http.Request) ([]byte, error) {
if db == nil {
return nil, fmt.Errorf("database is not initialized")
}
if token.ListenerID == 0 {
return nil, fmt.Errorf("access token is not bound to a listener")
}
var listener models.Listener
if err := db.First(&listener, token.ListenerID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, fmt.Errorf("listener not found")
}
return nil, fmt.Errorf("failed to fetch listener: %w", err)
}
if !listener.Enabled {
return nil, fmt.Errorf("listener is disabled")
}
serverHost := ResolveListenerServer(config.GlobalConfig, req, listener)
credentials := []user.Credential{}
if byListener, err := user.NewService(db).ActiveCredentialsByListener(); err != nil {
return nil, fmt.Errorf("failed to load listener credentials: %w", err)
} else {
credentials = byListener[listener.ID]
}
proxies, err := listenerToProxies(listener, serverHost, credentials)
if err != nil {
return nil, err
}
if len(proxies) == 0 {
return nil, fmt.Errorf("listener %q has no exportable client credentials", listener.Name)
}
names := make([]string, 0, len(proxies))
for _, proxy := range proxies {
if name, ok := proxy["name"].(string); ok {
names = append(names, name)
}
}
cfg := map[string]interface{}{
"proxies": proxies,
"proxy-groups": []interface{}{
map[string]interface{}{"name": "PROXY", "type": "select", "proxies": names},
},
"rules": []string{"MATCH,PROXY"},
}
return yaml.Marshal(cfg)
}
func listenerToProxies(l models.Listener, server string, credentials []user.Credential) ([]map[string]interface{}, error) {
protocol := strings.ToLower(strings.TrimSpace(l.Protocol))
if protocol == "" {
protocol = strings.ToLower(strings.TrimSpace(l.Type))
}
if !config.IsMihomoListenerProtocol(protocol) {
return nil, fmt.Errorf("unsupported listener protocol %q", protocol)
}
if protocol == "hysteria2-realm" {
return nil, fmt.Errorf("hysteria2-realm is an auxiliary realm service and has no client proxy configuration")
}
opts, err := decodeOptions(l.Config)
if err != nil {
return nil, fmt.Errorf("invalid listener config for %q: %w", l.Name, err)
}
portStr := ResolveListenerPort(l)
var portVal interface{} = portStr
if p, err := strconv.Atoi(portStr); err == nil {
portVal = p
}
server = netutil.NormalizeHost(server)
base := map[string]interface{}{"type": protocol, "server": server, "port": portVal}
if l.UDP && clientSupportsUDP(protocol) {
base["udp"] = true
}
copyClientTLS(base, opts)
copyTransport(base, opts)
makeProxy := func(suffix string) map[string]interface{} {
p := cloneMap(base)
name := l.Name
if suffix != "" {
name += "-" + suffix
}
p["name"] = name
return p
}
result := make([]map[string]interface{}, 0, max(1, len(credentials)))
switch protocol {
case "shadowsocks":
p := makeProxy(credentialSuffix(credentials, 0))
copyOption(p, opts, "cipher")
if len(credentials) > 1 {
return nil, fmt.Errorf("listener %q: Shadowsocks can export only one password", l.Name)
}
if len(credentials) == 1 && credentials[0].Password != "" {
p["password"] = credentials[0].Password
} else if value, ok := opts["password"]; ok {
p["password"] = value
}
if value, ok := opts["simple-obfs"].(map[string]interface{}); ok && boolValue(value["enable"]) {
p["plugin"] = "simple-obfs"
p["plugin-opts"] = value
}
applyClientWrappers(p, opts)
result = append(result, p)
case "snell":
p := makeProxy("")
copyOption(p, opts, "psk")
copyOption(p, opts, "version")
copyOption(p, opts, "udp")
if value, ok := opts["obfs-opts"]; ok {
p["obfs-opts"] = value
}
result = append(result, p)
case "vmess", "vless":
if len(credentials) == 0 {
return nil, fmt.Errorf("listener %q requires at least one active user for %s client export", l.Name, protocol)
}
for i, cred := range credentials {
if cred.UUID == "" {
continue
}
suffix := ""
if len(credentials) > 1 {
suffix = credentialSuffix(credentials, i)
if suffix == "" {
suffix = fmt.Sprintf("%d", i+1)
}
}
p := makeProxy(suffix)
p["uuid"] = cred.UUID
copyOption(p, opts, "cipher")
copyOption(p, opts, "packet-encoding")
copyOption(p, opts, "global-padding")
copyOption(p, opts, "authenticated-length")
// encryption (client) and decryption (server) are a generated pair from
// `mihomo generate vless-x25519` / `vless-mlkem768` — never copy decryption→encryption.
copyOption(p, opts, "encryption")
if flow := userFieldFromOpts(opts, cred.UUID, "flow"); flow != nil {
p["flow"] = flow
} else {
copyOption(p, opts, "flow")
}
if flow, ok := p["flow"].(string); ok && strings.Contains(strings.ToLower(flow), "vision") {
p["tls"] = true
}
if alterID := userFieldFromOpts(opts, cred.UUID, "alterId"); alterID != nil {
p["alterId"] = alterID
} else {
copyOption(p, opts, "alterId")
}
applyClientWrappers(p, opts)
result = append(result, p)
}
case "trojan":
if len(credentials) == 0 {
return nil, fmt.Errorf("listener %q requires at least one active user for Trojan client export", l.Name)
}
for i, cred := range credentials {
p := makeProxy(fmt.Sprintf("%d", i+1))
p["password"] = cred.Password
for _, key := range []string{"sni", "alpn", "fingerprint", "client-fingerprint", "skip-cert-verify", "name-cert-verify"} {
copyOption(p, opts, key)
}
applyClientWrappers(p, opts)
if value, ok := opts["ss-option"]; ok {
p["ss-opts"] = value
}
result = append(result, p)
}
case "hysteria2":
if len(credentials) == 0 {
return nil, fmt.Errorf("listener %q requires at least one active user for Hysteria 2 client export", l.Name)
}
for i, cred := range credentials {
p := makeProxy(fmt.Sprintf("%d", i+1))
p["password"] = cred.Password
for _, key := range []string{
"up", "down", "obfs", "obfs-password", "masquerade", "bbr-profile",
"realm-opts", "alpn", "sni", "servername", "skip-cert-verify", "name-cert-verify",
"fingerprint", "ca", "ca-str",
} {
copyOption(p, opts, key)
}
if p["alpn"] == nil {
p["alpn"] = []string{"h3"}
}
if p["sni"] == nil {
if sn, ok := p["servername"].(string); ok && sn != "" {
p["sni"] = sn
}
}
if value, ok := opts["ech-opts"]; ok {
p["ech-opts"] = value
}
result = append(result, p)
}
case "tuic":
if token, ok := opts["token"]; ok {
p := makeProxy("")
p["token"] = token
for _, key := range []string{"congestion-controller", "bbr-profile", "max-idle-time", "authentication-timeout", "alpn", "max-udp-relay-packet-size"} {
copyOption(p, opts, key)
}
result = append(result, p)
} else {
if len(credentials) == 0 {
return nil, fmt.Errorf("listener %q requires TUIC V5 users or a V4 token", l.Name)
}
for i, cred := range credentials {
p := makeProxy(fmt.Sprintf("%d", i+1))
p["uuid"] = cred.UUID
p["password"] = cred.Password
for _, key := range []string{"congestion-controller", "bbr-profile", "max-idle-time", "authentication-timeout", "alpn", "max-udp-relay-packet-size"} {
copyOption(p, opts, key)
}
result = append(result, p)
}
}
case "shadowquic":
if len(credentials) == 0 {
return nil, fmt.Errorf("listener %q requires at least one active user for ShadowQUIC client export", l.Name)
}
for i, cred := range credentials {
p := makeProxy(fmt.Sprintf("%d", i+1))
p["username"] = cred.Username
p["password"] = cred.Password
for _, key := range []string{
"sni", "alpn", "quic-versions", "zero-rtt", "udp-over-stream",
"keep-alive-interval", "congestion-controller", "up", "down", "cwnd",
"bbr-profile", "max-datagram-frame-size", "max-open-streams",
"recv-window-conn", "recv-window", "disable-mtu-discovery",
} {
copyOption(p, opts, key)
}
if p["sni"] == nil {
if ju, ok := opts["jls-upstream"].(map[string]interface{}); ok {
if sni, ok := ju["sni"].(string); ok && strings.TrimSpace(sni) != "" {
p["sni"] = sni
} else if addr, ok := ju["addr"].(string); ok && strings.TrimSpace(addr) != "" {
host := addr
if h, _, err := net.SplitHostPort(addr); err == nil {
host = h
}
p["sni"] = host
}
}
}
if p["udp"] == nil {
p["udp"] = true
}
if p["alpn"] == nil {
p["alpn"] = []string{"h3"}
}
if p["quic-versions"] == nil {
p["quic-versions"] = []string{"v2"}
}
if p["congestion-controller"] == nil {
p["congestion-controller"] = "bbr"
}
if p["zero-rtt"] == nil {
p["zero-rtt"] = true
}
result = append(result, p)
}
case "anytls":
if len(credentials) == 0 {
return nil, fmt.Errorf("listener %q requires at least one active user for AnyTLS client export", l.Name)
}
for i, cred := range credentials {
p := makeProxy(fmt.Sprintf("%d", i+1))
p["password"] = cred.Password
for _, key := range []string{
"client-fingerprint", "udp", "idle-session-check-interval",
"idle-session-timeout", "min-idle-session", "sni", "alpn",
"skip-cert-verify", "name-cert-verify",
} {
copyOption(p, opts, key)
}
applyClientWrappers(p, opts)
result = append(result, p)
}
case "mieru":
if len(credentials) == 0 {
return nil, fmt.Errorf("listener %q requires at least one active user for Mieru client export", l.Name)
}
for i, cred := range credentials {
p := makeProxy(fmt.Sprintf("%d", i+1))
p["username"] = cred.Username
p["password"] = cred.Password
for _, key := range []string{"transport", "multiplexing", "handshake-mode", "traffic-pattern"} {
copyOption(p, opts, key)
}
result = append(result, p)
}
case "sudoku":
p := makeProxy("")
for _, key := range []string{
"key", "aead-method", "padding-min", "padding-max", "table-type",
"custom-table", "custom-tables", "handshake-timeout",
"enable-pure-downlink", "httpmask",
} {
copyOption(p, opts, key)
}
result = append(result, p)
case "trusttunnel":
if len(credentials) == 0 {
return nil, fmt.Errorf("listener %q requires at least one active user for TrustTunnel client export", l.Name)
}
for i, cred := range credentials {
p := makeProxy(fmt.Sprintf("%d", i+1))
p["username"] = cred.Username
p["password"] = cred.Password
for _, key := range []string{
"client-fingerprint", "health-check", "udp", "sni", "alpn",
"skip-cert-verify", "name-cert-verify", "quic", "congestion-controller",
"bbr-profile", "max-connections", "min-streams", "max-streams",
} {
copyOption(p, opts, key)
}
result = append(result, p)
}
}
return result, nil
}
func applyClientWrappers(p map[string]interface{}, opts map[string]interface{}) {
if value := realityClientOptions(opts); value != nil {
p["reality-opts"] = value
if p["client-fingerprint"] == nil {
p["client-fingerprint"] = "chrome"
}
if p["tls"] == nil {
p["tls"] = true
}
if p["udp"] == nil {
p["udp"] = true
}
if p["alpn"] == nil {
p["alpn"] = []string{"h2", "http/1.1"}
}
if p["network"] == nil {
p["network"] = "tcp"
}
}
if value := shadowTLSClientOptions(opts); value != nil {
p["shadow-tls-opts"] = value
}
if value := resTLSClientOptions(opts); value != nil {
p["restls-opts"] = value
}
if value := jlsClientOptions(opts); value != nil {
p["jls-opts"] = value
}
}
func decodeOptions(raw string) (map[string]interface{}, error) {
if strings.TrimSpace(raw) == "" {
return map[string]interface{}{}, nil
}
var options map[string]interface{}
if err := json.Unmarshal([]byte(raw), &options); err != nil {
return nil, err
}
if options == nil {
options = map[string]interface{}{}
}
return options, nil
}
func copyClientTLS(dst, src map[string]interface{}) {
if _, ok := src["certificate"]; ok {
dst["tls"] = true
}
if _, ok := src["reality-config"]; ok {
dst["tls"] = true
}
if enabledWrapper(src, "shadow-tls") || enabledWrapper(src, "res-tls") || enabledWrapper(src, "jls-config") {
dst["tls"] = true
}
for _, key := range []string{"sni", "servername", "alpn", "fingerprint", "client-fingerprint", "skip-cert-verify", "name-cert-verify"} {
copyOption(dst, src, key)
}
if dst["sni"] == nil && dst["servername"] == nil {
if reality, ok := src["reality-config"].(map[string]interface{}); ok {
if sni, ok := firstStringValue(reality["server-names"]); ok {
dst["servername"] = sni
}
}
if dst["servername"] == nil {
if jls, ok := src["jls-config"].(map[string]interface{}); ok {
if sni, ok := jls["sni"].(string); ok && sni != "" {
dst["servername"] = sni
} else if dest, ok := jls["dest"].(string); ok && dest != "" {
host := dest
if h, _, err := net.SplitHostPort(dest); err == nil {
host = h
}
dst["servername"] = host
}
}
}
}
if dst["servername"] == nil {
if sni, ok := dst["sni"].(string); ok && sni != "" {
dst["servername"] = sni
}
}
}
func enabledWrapper(src map[string]interface{}, key string) bool {
raw, ok := src[key]
if !ok || raw == nil {
return false
}
if m, ok := raw.(map[string]interface{}); ok {
if en, ok := m["enable"].(bool); ok {
return en
}
return len(m) > 0
}
return false
}
func copyTransport(dst, src map[string]interface{}) {
if path, ok := src["ws-path"].(string); ok && strings.TrimSpace(path) != "" {
dst["network"] = "ws"
wsOpts := map[string]interface{}{"path": path}
if headers, ok := src["ws-headers"].(map[string]interface{}); ok && len(headers) > 0 {
wsOpts["headers"] = headers
}
dst["ws-opts"] = wsOpts
}
if service, ok := src["grpc-service-name"].(string); ok && strings.TrimSpace(service) != "" {
dst["network"] = "grpc"
dst["grpc-opts"] = map[string]interface{}{"grpc-service-name": service}
}
if value, ok := src["xhttp-config"]; ok && value != nil {
dst["network"] = "xhttp"
dst["xhttp-opts"] = value
}
}
func realityClientOptions(src map[string]interface{}) map[string]interface{} {
cfg, ok := src["reality-config"].(map[string]interface{})
if !ok {
return nil
}
publicKey, err := deriveRealityPublicKey(cfg)
if err != nil || publicKey == "" {
return nil
}
result := map[string]interface{}{"public-key": publicKey}
if ids, ok := cfg["short-id"].([]interface{}); ok && len(ids) > 0 {
result["short-id"] = ids[0]
} else if value, ok := cfg["short-id"]; ok {
result["short-id"] = value
}
if v, ok := cfg["support-x25519mlkem768"]; ok {
result["support-x25519mlkem768"] = v
} else {
result["support-x25519mlkem768"] = true
}
return result
}
func deriveRealityPublicKey(cfg map[string]interface{}) (string, error) {
if public, ok := cfg["public-key"].(string); ok && strings.TrimSpace(public) != "" {
return strings.TrimSpace(public), nil
}
private, ok := cfg["private-key"].(string)
if !ok || strings.TrimSpace(private) == "" {
return "", fmt.Errorf("reality-config missing public-key and private-key")
}
var raw []byte
var err error
for _, decode := range []func(string) ([]byte, error){
base64.RawStdEncoding.DecodeString, base64.StdEncoding.DecodeString,
base64.RawURLEncoding.DecodeString, base64.URLEncoding.DecodeString,
} {
raw, err = decode(strings.TrimSpace(private))
if err == nil && len(raw) == 32 {
break
}
raw = nil
}
if len(raw) != 32 {
return "", fmt.Errorf("invalid Reality private key")
}
public, err := curve25519.X25519(raw, curve25519.Basepoint)
if err != nil {
return "", err
}
return base64.RawStdEncoding.EncodeToString(public), nil
}
func firstStringValue(v interface{}) (string, bool) {
if s, ok := v.(string); ok {
return s, s != ""
}
if a, ok := v.([]interface{}); ok && len(a) > 0 {
s, _ := a[0].(string)
return s, s != ""
}
if a, ok := v.([]string); ok && len(a) > 0 {
return a[0], a[0] != ""
}
return "", false
}
func shadowTLSClientOptions(src map[string]interface{}) map[string]interface{} {
cfg, ok := src["shadow-tls"].(map[string]interface{})
if !ok {
return nil
}
result := map[string]interface{}{}
for _, key := range []string{"version", "password"} {
if value, ok := cfg[key]; ok {
result[key] = value
}
}
if len(result) == 0 {
return nil
}
return result
}
func resTLSClientOptions(src map[string]interface{}) map[string]interface{} {
cfg, ok := src["res-tls"].(map[string]interface{})
if !ok {
return nil
}
result := map[string]interface{}{}
if value, ok := cfg["password"]; ok {
result["password"] = value
}
if value, ok := cfg["version-hint"]; ok {
result["version-hint"] = value
}
if len(result) == 0 {
return nil
}
return result
}
func jlsClientOptions(src map[string]interface{}) map[string]interface{} {
cfg, ok := src["jls-config"].(map[string]interface{})
if !ok {
return nil
}
result := map[string]interface{}{}
if users, ok := cfg["users"].([]interface{}); ok && len(users) > 0 {
if first, ok := users[0].(map[string]interface{}); ok {
if value, ok := first["username"]; ok {
result["username"] = value
}
if value, ok := first["password"]; ok {
result["password"] = value
}
}
}
if len(result) == 0 {
return nil
}
return result
}
func cloneMap(src map[string]interface{}) map[string]interface{} {
dst := make(map[string]interface{}, len(src))
for key, value := range src {
dst[key] = value
}
return dst
}
func copyOption(dst, src map[string]interface{}, key string) {
if value, ok := src[key]; ok {
dst[key] = value
}
}
func boolValue(v interface{}) bool {
b, _ := v.(bool)
return b
}
func clientSupportsUDP(protocol string) bool {
switch protocol {
case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel":
return true
default:
return false
}
}
func credentialSuffix(credentials []user.Credential, index int) string {
if index >= 0 && index < len(credentials) && credentials[index].Username != "" {
return credentials[index].Username
}
return ""
}
func userFieldFromOpts(opts map[string]interface{}, uuid, field string) interface{} {
uuid = strings.TrimSpace(uuid)
if uuid == "" {
return nil
}
list, ok := opts["users"].([]interface{})
if !ok {
return nil
}
for _, item := range list {
row, ok := item.(map[string]interface{})
if !ok {
continue
}
if strings.TrimSpace(fmt.Sprint(row["uuid"])) != uuid {
continue
}
if value, exists := row[field]; exists && value != nil && fmt.Sprint(value) != "" {
return value
}
}
return nil
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
PLACEHOLDER3
+1 -301
View File
@@ -1,301 +1 @@
package node
import (
"crypto/rand"
"encoding/hex"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/kazeyukiro/3m-ui/backend/internal/config"
"github.com/kazeyukiro/3m-ui/backend/internal/converter"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"github.com/kazeyukiro/3m-ui/backend/internal/netutil"
"github.com/kazeyukiro/3m-ui/backend/internal/protocol"
"github.com/kazeyukiro/3m-ui/backend/internal/user"
"gorm.io/gorm"
)
// Handler serves node/listener HTTP endpoints with injected dependencies.
type Handler struct {
svc *Service
user *user.Service
db *gorm.DB
}
// NewHandler constructs a node HTTP handler.
func NewHandler(svc *Service, userSvc *user.Service, db *gorm.DB) *Handler {
return &Handler{svc: svc, user: userSvc, db: db}
}
// RegisterRoutes registers node routes on the provided group.
func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("", h.ListNodes)
rg.POST("", h.CreateNode)
rg.GET("/:id", h.GetNode)
rg.GET("/:id/uri", h.ExportNodeURI)
rg.PUT("/:id", h.UpdateNode)
rg.DELETE("/:id", h.DeleteNode)
rg.POST("/:id/reload", h.ReloadNode)
rg.POST("/:id/client-access", h.CreateClientAccess)
}
// RegisterClientRoutes registers only routes that are unique to the node
// handler. The listener handler already owns the shared CRUD/reload routes.
func (h *Handler) RegisterClientRoutes(rg *gin.RouterGroup) {
rg.GET("/:id/uri", h.ExportNodeURI)
rg.POST("/:id/client-access", h.CreateClientAccess)
}
func (h *Handler) ListNodes(c *gin.Context) {
list, err := h.svc.GetAll()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, list)
}
func (h *Handler) CreateNode(c *gin.Context) {
var l models.Listener
if err := c.ShouldBindJSON(&l); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := ValidateNode(&l); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.Create(&l); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, l)
}
func (h *Handler) GetNode(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
return
}
l, err := h.svc.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, l)
}
// isTrustedHost validates that the provided host is acceptable for URI export.
// It rejects path traversal, illegal characters, and hosts that do not match
// the configured public_url (if set).
func (h *Handler) isTrustedHost(host string) bool {
host = strings.TrimSpace(host)
if host == "" {
return false
}
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
if strings.ContainsAny(host, "/\\?#@") || strings.Contains(host, "..") {
return false
}
if config.GlobalConfig != nil && config.GlobalConfig.Server.PublicURL != "" {
expected := config.GlobalConfig.Server.PublicURL
if u, err := url.Parse(expected); err == nil && u.Host != "" {
expected = u.Host
}
if eh, _, err := net.SplitHostPort(expected); err == nil {
expected = eh
}
if !strings.EqualFold(host, expected) {
return false
}
}
return true
}
// ExportNodeURI returns one URI per active client credential. Public host
// comes from public_url, request Host, or loopback as last resort.
func (h *Handler) ExportNodeURI(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
return
}
listener, err := h.svc.GetByID(uint(id))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
return
}
if !listener.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"})
return
}
publicURL := ""
if config.GlobalConfig != nil {
publicURL = strings.TrimSpace(config.GlobalConfig.Server.PublicURL)
}
// Priority: per-node Access Profile > global panel Access Profile > server.public_url
if ap := protocol.LoadAccessProfile(h.db); ap.PublicHost != "" {
publicURL = ap.PublicHost
}
if host := strings.TrimSpace(listener.PublicHost); host != "" {
publicURL = host
}
host := c.GetHeader("X-Forwarded-Host")
if host != "" && !h.isTrustedHost(host) {
host = ""
}
if host == "" {
host = c.Request.Host
}
host = netutil.NormalizeHost(host)
host = normalizeExportHostPrefer(host, listener.BindAddress, listener.Listen, publicURL)
host = netutil.NormalizeHost(host)
credentials := []user.Credential{}
if h.user != nil {
byListener, err := h.user.ActiveCredentialsByListener()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load active credentials: " + err.Error()})
return
}
credentials = byListener[listener.ID]
}
uris, err := ClientURIsWithCredentials(*listener, host, credentials)
if err != nil {
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
return
}
if uris == nil {
uris = []string{}
}
c.JSON(http.StatusOK, gin.H{
"name": listener.Name,
"protocol": listener.Protocol,
"uris": uris,
"hint": emptyURIHint(len(uris), len(credentials)),
})
}
func emptyURIHint(uriCount, credCount int) string {
if uriCount > 0 {
return ""
}
if credCount == 0 {
return "no active users bound to this listener; bind users in User Management"
}
return "could not build share links for this protocol/config"
}
func (h *Handler) UpdateNode(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
return
}
var l models.Listener
if err := c.ShouldBindJSON(&l); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
l.ID = uint(id)
if err := ValidateNode(&l); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.svc.Update(&l); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, l)
}
func (h *Handler) DeleteNode(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
return
}
if err := h.svc.Delete(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node deleted"})
}
func (h *Handler) ReloadNode(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"})
return
}
if err := h.svc.TriggerReload(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node reloaded"})
}
func (h *Handler) CreateClientAccess(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"})
return
}
db := h.db
if db == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"})
return
}
var listener models.Listener
if err := db.First(&listener, uint(id)).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"})
return
}
if !listener.Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"})
return
}
var existing models.AccessToken
if err := db.Where("listener_id = ? AND enabled = ?", listener.ID, true).First(&existing).Error; err == nil {
if existing.ExpireAt == nil || existing.ExpireAt.After(time.Now()) {
c.JSON(http.StatusOK, h.clientAccessResponse(c, existing))
return
}
}
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secure token"})
return
}
token := models.AccessToken{Name: listener.Name, Token: hex.EncodeToString(buf), Enabled: true, ListenerID: listener.ID}
if err := db.Create(&token).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, h.clientAccessResponse(c, token))
}
func (h *Handler) clientAccessResponse(c *gin.Context, token models.AccessToken) gin.H {
cfg := config.GlobalConfig
return gin.H{
"id": token.ID,
"name": token.Name,
"token": token.Token,
"type": "listener",
"listener_id": token.ListenerID,
"mihomo_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "mihomo"),
"clash_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "clash"),
"singbox_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "singbox"),
"shadowrocket_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "shadowrocket"),
}
}
PLACEHOLDER2
+1 -486
View File
@@ -1,486 +1 @@
package node
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/kazeyukiro/3m-ui/backend/internal/database/models"
"github.com/kazeyukiro/3m-ui/backend/internal/netutil"
"golang.org/x/crypto/curve25519"
)
// ClientURIs exports client share links from the same Listener schema used by
// Mihomo config generation. Server-only fields are never copied blindly.
func ClientURIs(listener models.Listener, host string) ([]string, error) {
host = netutil.NormalizeHost(host)
if host == "" {
host = netutil.NormalizeHost(listener.PublicHost)
}
if host == "" {
host = normalizeExportHost("", listener.BindAddress, listener.Listen)
}
if host == "" {
return nil, fmt.Errorf("cannot determine public host for listener; set public_host / server.public_url or access via a public hostname (IPv4/IPv6 supported)")
}
cfg, err := decodeURIConfig(listener.Config)
if err != nil {
return nil, err
}
cfg["_listener-tls"] = listener.TLS
cfg["_listener-udp"] = listener.UDP
port := strings.TrimSpace(listener.PublicPort)
if port == "" {
port = strings.TrimSpace(listener.Port)
}
if strings.ContainsAny(port, ",-") {
return nil, fmt.Errorf("URI export requires a single listener port; ranges and port lists are not representable in a share URI")
}
switch strings.ToLower(listener.Protocol) {
case "shadowsocks":
return shadowsocksURIs(listener.Name, host, port, cfg)
case "snell":
return snellURIs(listener.Name, host, port, cfg)
case "vless":
return vlessURIs(listener.Name, host, port, cfg)
case "vmess":
return vmessURIs(listener.Name, host, port, cfg)
case "trojan":
return trojanURIs(listener.Name, host, port, cfg)
case "hysteria2":
return hysteria2URIs(listener.Name, host, port, cfg)
case "tuic":
return tuicURIs(listener.Name, host, port, cfg)
case "shadowquic":
return shadowQUICURIs(listener.Name, host, port, cfg)
case "anytls":
return anytlsURIs(listener.Name, host, port, cfg)
case "mieru":
return mieruURIs(listener.Name, host, port, cfg)
case "sudoku":
return sudokuURIs(listener.Name, host, port, cfg)
case "trusttunnel":
return trustTunnelURIs(listener.Name, host, port, cfg)
default:
return nil, fmt.Errorf("URI export is not supported for listener protocol %q", listener.Protocol)
}
}
func decodeURIConfig(raw string) (map[string]interface{}, error) {
if strings.TrimSpace(raw) == "" {
return map[string]interface{}{}, nil
}
var cfg map[string]interface{}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, fmt.Errorf("invalid listener configuration: %w", err)
}
if cfg == nil {
return map[string]interface{}{}, nil
}
return cfg, nil
}
func userMap(cfg map[string]interface{}) map[string]interface{} {
if users, ok := cfg["users"].(map[string]interface{}); ok {
return users
}
return nil
}
func userRows(cfg map[string]interface{}) []map[string]interface{} {
users, _ := cfg["users"].([]interface{})
rows := make([]map[string]interface{}, 0, len(users))
for _, raw := range users {
if row, ok := raw.(map[string]interface{}); ok {
rows = append(rows, row)
}
}
return rows
}
func query(base string, values map[string]string) string {
q := url.Values{}
for k, v := range values {
if v != "" {
q.Set(k, v)
}
}
if encoded := q.Encode(); encoded != "" {
return base + "?" + encoded
}
return base
}
func addName(uri, name string) string {
if name == "" {
return uri
}
return uri + "#" + url.QueryEscape(name)
}
func tlsParams(cfg map[string]interface{}) map[string]string {
params := map[string]string{}
if enabled, ok := cfg["_listener-tls"].(bool); ok && enabled {
params["security"] = "tls"
}
if certificate, ok := cfg["certificate"].(string); ok && strings.TrimSpace(certificate) != "" {
params["security"] = "tls"
}
if v, ok := cfg["servername"].(string); ok && v != "" {
params["sni"] = v
}
if v, ok := cfg["sni"].(string); ok && v != "" {
params["sni"] = v
}
if v, ok := cfg["client-fingerprint"].(string); ok && v != "" {
params["fp"] = v
}
if v, ok := cfg["fingerprint"].(string); ok && v != "" {
params["fp"] = v
}
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
params["allowInsecure"] = "1"
}
return params
}
func transportParams(cfg map[string]interface{}) map[string]string {
params := map[string]string{}
if v, ok := cfg["ws-path"].(string); ok && v != "" {
params["type"] = "ws"
params["path"] = v
}
if headers, ok := cfg["ws-headers"].(map[string]interface{}); ok {
if host, ok := headers["Host"].(string); ok && host != "" {
params["host"] = host
}
}
if v, ok := cfg["grpc-service-name"].(string); ok && v != "" {
params["type"] = "grpc"
params["serviceName"] = v
}
if xhttp, ok := cfg["xhttp-config"].(map[string]interface{}); ok {
params["type"] = "xhttp"
if path, ok := xhttp["path"].(string); ok && path != "" {
params["path"] = path
}
}
return params
}
func shadowsocksURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
cipher, _ := cfg["cipher"].(string)
password, _ := cfg["password"].(string)
if cipher == "" || password == "" {
return nil, fmt.Errorf("shadowsocks listener requires cipher and password for URI export")
}
encoded := base64.RawStdEncoding.EncodeToString([]byte(cipher + ":" + password))
return []string{addName("ss://"+encoded+"@"+netutil.JoinHostPort(host, port), name)}, nil
}
func vlessURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
rows := userRows(cfg)
if len(rows) == 0 {
return nil, fmt.Errorf("vless listener requires at least one user for URI export")
}
result := make([]string, 0, len(rows))
for _, row := range rows {
uuid, _ := row["uuid"].(string)
if uuid == "" {
return nil, fmt.Errorf("vless user uuid is required")
}
params := tlsParams(cfg)
params["type"] = "tcp"
if flow, _ := row["flow"].(string); flow != "" {
params["flow"] = flow
}
// Client URI uses encryption (not server-side decryption).
if encryption, _ := cfg["encryption"].(string); encryption != "" {
params["encryption"] = encryption
} else {
params["encryption"] = "none"
}
if reality, ok := cfg["reality-config"].(map[string]interface{}); ok {
params["security"] = "reality"
publicKey, err := realityPublicKey(reality)
if err != nil {
return nil, err
}
params["pbk"] = publicKey
if sid := realityShortID(reality); sid != "" {
params["sid"] = sid
}
if sni, ok := firstString(reality["server-names"]); ok {
params["sni"] = sni
}
if params["fp"] == "" {
params["fp"] = "chrome"
}
}
for k, v := range transportParams(cfg) {
params[k] = v
}
result = append(result, addName(query("vless://"+url.PathEscape(uuid)+"@"+netutil.JoinHostPort(host, port), params), name))
}
return result, nil
}
func vmessURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
rows := userRows(cfg)
if len(rows) == 0 {
return nil, fmt.Errorf("vmess listener requires at least one user for URI export")
}
result := make([]string, 0, len(rows))
for _, row := range rows {
uuid, _ := row["uuid"].(string)
if uuid == "" {
return nil, fmt.Errorf("vmess user uuid is required")
}
aid := stringValue(cfg["alterId"], "0")
cipher := stringValue(cfg["cipher"], "auto")
obj := map[string]string{"v": "2", "ps": name, "add": host, "port": port, "id": uuid, "aid": aid, "scy": cipher, "net": "tcp", "type": "none"}
if tls, ok := tlsParams(cfg)["security"]; ok && tls == "tls" {
obj["tls"] = "tls"
}
if sni := tlsParams(cfg)["sni"]; sni != "" {
obj["sni"] = sni
}
if fp := tlsParams(cfg)["fp"]; fp != "" {
obj["fp"] = fp
}
if ws, ok := cfg["ws-path"].(string); ok && ws != "" {
obj["net"] = "ws"
obj["path"] = ws
}
if grpc, ok := cfg["grpc-service-name"].(string); ok && grpc != "" {
obj["net"] = "grpc"
obj["path"] = grpc
}
if reality, ok := cfg["reality-config"].(map[string]interface{}); ok {
// Share-link convention: mark Reality (not plain TLS).
obj["tls"] = "reality"
publicKey, err := realityPublicKey(reality)
if err != nil {
return nil, err
}
obj["pbk"] = publicKey
if sid := realityShortID(reality); sid != "" {
obj["sid"] = sid
}
if sni, ok := firstString(reality["server-names"]); ok {
obj["sni"] = sni
}
if obj["fp"] == "" {
obj["fp"] = "chrome"
}
}
data, err := json.Marshal(obj)
if err != nil {
return nil, err
}
result = append(result, "vmess://"+base64.RawStdEncoding.EncodeToString(data))
}
return result, nil
}
func trojanURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
rows := userRows(cfg)
if len(rows) == 0 {
return nil, fmt.Errorf("trojan listener requires at least one user for URI export")
}
result := make([]string, 0, len(rows))
for _, row := range rows {
password, _ := row["password"].(string)
if password == "" {
return nil, fmt.Errorf("trojan user password is required")
}
params := tlsParams(cfg)
for k, v := range transportParams(cfg) {
params[k] = v
}
if reality, ok := cfg["reality-config"].(map[string]interface{}); ok {
params["security"] = "reality"
publicKey, err := realityPublicKey(reality)
if err != nil {
return nil, err
}
params["pbk"] = publicKey
if sid := realityShortID(reality); sid != "" {
params["sid"] = sid
}
if params["sni"] == "" {
if sni, ok := firstString(reality["server-names"]); ok {
params["sni"] = sni
}
}
if params["fp"] == "" {
params["fp"] = "chrome"
}
}
result = append(result, addName(query("trojan://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
}
return result, nil
}
func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
users := userMap(cfg)
if len(users) == 0 {
return nil, fmt.Errorf("hysteria2 listener requires at least one user for URI export")
}
result := make([]string, 0, len(users))
for username, raw := range users {
password, ok := raw.(string)
if !ok || password == "" {
return nil, fmt.Errorf("hysteria2 user %q has empty password", username)
}
params := map[string]string{}
if v, ok := cfg["sni"].(string); ok && v != "" {
params["sni"] = v
}
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
params["insecure"] = "1"
}
if v, ok := cfg["obfs"].(string); ok && v != "" {
params["obfs"] = v
}
if v, ok := cfg["obfs-password"].(string); ok && v != "" {
params["obfs-password"] = v
}
if v, ok := cfg["up"].(string); ok && v != "" {
params["up"] = v
}
if v, ok := cfg["down"].(string); ok && v != "" {
params["down"] = v
}
_ = username
result = append(result, addName(query("hysteria2://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
}
return result, nil
}
func tuicURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
if token, ok := cfg["token"].(string); ok && strings.TrimSpace(token) != "" {
return []string{addName("tuic://"+url.PathEscape(token)+"@"+netutil.JoinHostPort(host, port), name)}, nil
}
users := userMap(cfg)
if len(users) == 0 {
return nil, fmt.Errorf("tuic V5 listener requires at least one user for URI export")
}
result := make([]string, 0, len(users))
for uuid, raw := range users {
password, ok := raw.(string)
if !ok || password == "" {
return nil, fmt.Errorf("tuic user %q has empty password", uuid)
}
params := map[string]string{}
for key, out := range map[string]string{"congestion-controller": "congestion_control", "bbr-profile": "bbr_profile", "udp-relay-mode": "udp_relay_mode", "max-udp-relay-packet-size": "max_udp_relay_packet_size"} {
if v, ok := cfg[key].(string); ok && v != "" {
params[out] = v
}
}
if v, ok := firstString(cfg["alpn"]); ok {
params["alpn"] = v
}
if v, ok := cfg["sni"].(string); ok && v != "" {
params["sni"] = v
}
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
params["allow_insecure"] = "1"
}
result = append(result, addName(query("tuic://"+url.PathEscape(uuid)+":"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
}
return result, nil
}
func anytlsURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) {
users := userMap(cfg)
if len(users) == 0 {
return nil, fmt.Errorf("anytls listener requires at least one user for URI export")
}
result := make([]string, 0, len(users))
for username, raw := range users {
password, ok := raw.(string)
if !ok || password == "" {
return nil, fmt.Errorf("anytls user %q has empty password", username)
}
params := map[string]string{}
if v, ok := cfg["sni"].(string); ok && v != "" {
params["sni"] = v
}
if v, ok := cfg["client-fingerprint"].(string); ok && v != "" {
params["fp"] = v
}
if b, ok := cfg["skip-cert-verify"].(bool); ok && b {
params["insecure"] = "1"
}
if v, ok := cfg["idle-session-check-interval"].(string); ok && v != "" {
params["idle_session_check_interval"] = v
}
if v, ok := cfg["idle-session-timeout"].(string); ok && v != "" {
params["idle_session_timeout"] = v
}
if v, ok := cfg["min-idle-session"].(string); ok && v != "" {
params["min_idle_session"] = v
}
_ = username
result = append(result, addName(query("anytls://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name))
}
return result, nil
}
func realityPublicKey(cfg map[string]interface{}) (string, error) {
if public, ok := cfg["public-key"].(string); ok && strings.TrimSpace(public) != "" {
return public, nil
}
private, ok := cfg["private-key"].(string)
if !ok || strings.TrimSpace(private) == "" {
return "", fmt.Errorf("reality listener URI export requires reality-config.public-key or private-key")
}
var raw []byte
var err error
for _, decode := range []func(string) ([]byte, error){base64.RawStdEncoding.DecodeString, base64.StdEncoding.DecodeString, base64.RawURLEncoding.DecodeString, base64.URLEncoding.DecodeString} {
raw, err = decode(strings.TrimSpace(private))
if err == nil && len(raw) == 32 {
break
}
}
if len(raw) != 32 {
return "", fmt.Errorf("invalid Reality private key: expected 32 decoded bytes")
}
public, err := curve25519.X25519(raw, curve25519.Basepoint)
if err != nil {
return "", fmt.Errorf("failed to derive Reality public key: %w", err)
}
return base64.RawStdEncoding.EncodeToString(public), nil
}
// realityShortID returns the first short-id for client URI (official field is []string).
func realityShortID(reality map[string]interface{}) string {
if s, ok := firstString(reality["short-id"]); ok {
return s
}
return ""
}
func firstString(v interface{}) (string, bool) {
if s, ok := v.(string); ok {
return s, s != ""
}
if a, ok := v.([]interface{}); ok && len(a) > 0 {
s, _ := a[0].(string)
return s, s != ""
}
if a, ok := v.([]string); ok && len(a) > 0 {
return a[0], a[0] != ""
}
return "", false
}
func stringValue(v interface{}, fallback string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return fallback
}
PLACEHOLDER