diff --git a/backend/internal/converter/client.go b/backend/internal/converter/client.go index fb01e1a..decf8f3 100644 --- a/backend/internal/converter/client.go +++ b/backend/internal/converter/client.go @@ -348,10 +348,9 @@ func listenerToProxies(l models.Listener, server string, credentials []user.Cred p["sni"] = sn } } + // Only panel self-signed (or explicit skip) — not operator-provided formal certs. if certutil.ShouldSkipCertVerify(opts) { p["skip-cert-verify"] = true - } else if cert, _ := opts["certificate"].(string); strings.TrimSpace(cert) != "" { - p["skip-cert-verify"] = true } if value, ok := opts["ech-opts"]; ok { p["ech-opts"] = value @@ -360,7 +359,7 @@ func listenerToProxies(l models.Listener, server string, credentials []user.Cred } case "tuic", "tuic-v4", "tuic-v5": // MetaCubeX proxies/tuic: type is always "tuic". - // V4 = token (string); V5 = uuid + password. Prefer Config token/users. + // V4 = token from Config only; V5 = uuid+password. copyTUICOpts := func(p map[string]interface{}) { for _, key := range []string{ "congestion-controller", "bbr-profile", "alpn", "max-udp-relay-packet-size", @@ -376,17 +375,26 @@ func listenerToProxies(l models.Listener, server string, credentials []user.Cred p["name-cert-verify"] = v } ensureTUICClientDefaults(p, opts) + // Formal cert: keep SNI as public host. Self-signed: skip-verify already set. if p["sni"] == nil && p["servername"] == nil && server != "" { p["sni"] = server } } - if tuicTokenNonEmpty(opts["token"]) { + // Prefer Config token for any TUIC that still has token (v4 / dual). + if tuicTokenNonEmpty(opts["token"]) && protocol != "tuic-v5" { p := makeProxy("") - p["token"] = normalizeTUICToken(opts["token"]) + tok := normalizeTUICToken(opts["token"]) + if s, ok := tok.(string); !ok || strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("listener %q: empty TUIC token in config", l.Name) + } + p["token"] = tok copyTUICOpts(p) result = append(result, p) break } + if protocol == "tuic-v4" { + return nil, fmt.Errorf("listener %q: tuic-v4 requires non-empty token in listener config", l.Name) + } // V5 users from credentials (already preferred from Config when present). emitted := 0 for i, cred := range credentials { @@ -830,16 +838,16 @@ func ensureTUICClientDefaults(p, opts map[string]interface{}) { if p["congestion-controller"] == nil { p["congestion-controller"] = "bbr" } - // Panel mints self-signed PEMs (CN=localhost). Public host (domain/IP) - // will never match — clients must skip verify or the handshake fails. + // Panel self-signed only. Formal certs (Let's Encrypt etc.) must verify. if certutil.ShouldSkipCertVerify(opts) { p["skip-cert-verify"] = true - } else if cert, _ := opts["certificate"].(string); strings.TrimSpace(cert) != "" { - p["skip-cert-verify"] = true } if p["udp"] == nil { p["udp"] = true } + if p["udp-relay-mode"] == nil { + p["udp-relay-mode"] = "native" + } } func copyClientTLS(dst, src map[string]interface{}) { diff --git a/backend/internal/protocol/decode.go b/backend/internal/protocol/decode.go index 4769571..d01817a 100644 --- a/backend/internal/protocol/decode.go +++ b/backend/internal/protocol/decode.go @@ -131,14 +131,18 @@ func DecodeNodeModel(l models.Listener, users []UserCred) (NodeModel, error) { n.Users = []UserCred{{Password: key}} } } - // TUIC v4: token: [TOKEN] (wiki inbound). Export one share per token. - if len(n.Users) == 0 && (n.Protocol == "tuic-v4" || n.Protocol == "tuic") { + // TUIC v4: token lives only in Config. Panel user rows must not become the token. + if n.Protocol == "tuic-v4" || (n.Protocol == "tuic" && len(stringListFrom(cfg, "token")) > 0) { + var toks []UserCred for _, tok := range stringListFrom(cfg, "token") { tok = strings.TrimSpace(tok) if tok != "" { - n.Users = append(n.Users, UserCred{Password: tok}) + toks = append(toks, UserCred{Password: tok}) } } + if len(toks) > 0 { + n.Users = toks + } } } return n, nil diff --git a/backend/internal/protocol/share_modules.go b/backend/internal/protocol/share_modules.go index d2e7b44..5d7a313 100644 --- a/backend/internal/protocol/share_modules.go +++ b/backend/internal/protocol/share_modules.go @@ -624,9 +624,14 @@ func (t TUICCompiler) BuildShare(in ShareInput) (Share, error) { if sni == "" { sni = strings.TrimSpace(in.Node.PublicHost) } - skipCert := true + skipCert := false if v, ok := cfg["skip-cert-verify"].(bool); ok { skipCert = v + } else if cert, _ := cfg["certificate"].(string); strings.Contains(cert, "3m-ui") { + // Panel GenerateSelfSigned uses O=3m-ui + skipCert = true + } else if cert, _ := cfg["certificate"].(string); strings.TrimSpace(cert) != "" && sni == "" { + skipCert = true } isV4 := kind == "tuic-v4" @@ -672,14 +677,18 @@ func (t TUICCompiler) BuildShare(in ShareInput) (Share, error) { var uri string if isV4 { - token := strings.TrimSpace(in.User.Password) + // Token lives on the listener Config — never the panel user password. + token := firstNonEmptyTokenValue(cfg["token"]) if token == "" { - token = strings.TrimSpace(in.User.Username) + token = strings.TrimSpace(in.User.Password) + // Only accept User.Password when it came from config decode (no UUID). + if strings.TrimSpace(in.User.UUID) != "" || looksLikeUUID(in.User.Username) { + token = "" + } } if token == "" { - return Share{}, fmt.Errorf("tuic-v4 share requires token") + return Share{}, fmt.Errorf("tuic-v4 share requires token in listener config") } - // Client URI: tuic://TOKEN@host:port?... (V4 has no uuid/password). uri = shareName( shareQuery("tuic://"+url.PathEscape(token)+"@"+netutil.JoinHostPort(host, port), params), in.Node.Name, @@ -847,3 +856,25 @@ func strOr(v, def string) string { } return def } + +func firstNonEmptyTokenValue(tok interface{}) string { + switch v := tok.(type) { + case string: + return strings.TrimSpace(v) + case []string: + for _, s := range v { + if s = strings.TrimSpace(s); s != "" { + return s + } + } + case []interface{}: + for _, item := range v { + if s, ok := item.(string); ok { + if s = strings.TrimSpace(s); s != "" { + return s + } + } + } + } + return "" +}