diff --git a/backend/internal/mihomo/config/listener_schema_registry.go b/backend/internal/mihomo/config/listener_schema_registry.go index 40ab939..38b68f4 100644 --- a/backend/internal/mihomo/config/listener_schema_registry.go +++ b/backend/internal/mihomo/config/listener_schema_registry.go @@ -176,8 +176,11 @@ var MihomoListenerSchemas = map[string]ListenerSchema{ ), }, "tuic-v4": { - Protocol: "tuic-v4", - Fields: listenerFields("users", "token", "certificate", "private-key", "client-auth-type", "client-auth-cert", "ech-key", "congestion-controller", "bbr-profile", "max-idle-time", "authentication-timeout", "alpn", "max-udp-relay-packet-size", "mux-option"), + Protocol: "tuic-v4", + // v4 is token-only per wiki — `users` is intentionally NOT in Fields + // (validator rejects v4-with-users via validateProtocolSpecific). + // Symmetric to tuic-v5 which omits `token`. + Fields: listenerFields("token", "certificate", "private-key", "client-auth-type", "client-auth-cert", "ech-key", "congestion-controller", "bbr-profile", "max-idle-time", "authentication-timeout", "alpn", "max-udp-relay-packet-size", "mux-option"), NestedFields: listenerNested("mux-option.padding", "mux-option.brutal", "mux-option.brutal.enabled", "mux-option.brutal.up", "mux-option.brutal.down"), }, "tuic-v5": { diff --git a/backend/internal/node/uri_builders.go b/backend/internal/node/uri_builders.go index 8f59157..e652f46 100644 --- a/backend/internal/node/uri_builders.go +++ b/backend/internal/node/uri_builders.go @@ -297,6 +297,31 @@ func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]strin } func tuicURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { + // Build the shared query-params map ONCE. Mihomo's TUIC URI parser expects + // snake_case keys (congestion_control, udp_relay_mode, max_udp_relay_packet_size, + // bbr_profile, allow_insecure) — NOT the YAML/listener hyphen-case form. + // Refs: protocol/share_modules.go BuildShare uses the same snake_case. + 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 certutil.ShouldSkipCertVerify(cfg) { + params["allow_insecure"] = "1" + } + // token is an array of strings per tuic-v4 (Mihomo listener config). if tokens, ok := cfg["token"].([]interface{}); ok && len(tokens) > 0 { result := make([]string, 0, len(tokens)) @@ -305,7 +330,10 @@ func tuicURIs(name, host, port string, cfg map[string]interface{}) ([]string, er if !ok || strings.TrimSpace(ts) == "" { continue } - result = append(result, addName("tuic://"+url.PathEscape(ts)+"@"+netutil.JoinHostPort(host, port), name)) + // v4 URI: tuic://@host:port?# + // Pre-fix this path emitted zero query params, so v4 clients + // lost congestion_control / udp_relay_mode / alpn / sni / allow_insecure. + result = append(result, addName(query("tuic://"+url.PathEscape(ts)+"@"+netutil.JoinHostPort(host, port), params), name)) } if len(result) > 0 { return result, nil @@ -323,21 +351,6 @@ func tuicURIs(name, host, port string, cfg map[string]interface{}) ([]string, er if uuid == "" || password == "" { continue } - params := map[string]string{} - for key, out := range map[string]string{"congestion-controller": "congestion-controller", "bbr-profile": "bbr-profile", "udp-relay-mode": "udp-relay-mode"} { - 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 certutil.ShouldSkipCertVerify(cfg) { - params["allow_insecure"] = "1" - } result = append(result, addName(query("tuic://"+url.PathEscape(uuid)+":"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name)) } if len(result) > 0 { @@ -354,21 +367,6 @@ func tuicURIs(name, host, port string, cfg map[string]interface{}) ([]string, er 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-controller", "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 certutil.ShouldSkipCertVerify(cfg) { - 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 diff --git a/backend/internal/node/validator.go b/backend/internal/node/validator.go index ba56e7e..0d37e82 100644 --- a/backend/internal/node/validator.go +++ b/backend/internal/node/validator.go @@ -147,7 +147,20 @@ func validateObject(proto, prefix string, value map[string]interface{}, allowed return nil } -var mapUserProtocols = map[string]bool{"anytls": true, "hysteria2": true, "mieru": true, "tuic": true} +// mapUserProtocols lists protocols whose `users` field is a free-form map +// (UUID→password) or array of {username,password,...} objects, where the +// per-user keys must not be validated against the schema's NestedFields. +// - anytls / hysteria2 / mieru: map UUID→password +// - tuic (generic) / tuic-v5: v5 form is map UUID→password per wiki +// - tuic-v4 is intentionally NOT here — v4 uses `token: [string]`, and any +// `users` field on v4 is rejected by validateProtocolSpecific (line ~190). +var mapUserProtocols = map[string]bool{ + "anytls": true, + "hysteria2": true, + "mieru": true, + "tuic": true, + "tuic-v5": true, +} func joinField(prefix, field string) string { if prefix == "" { diff --git a/backend/internal/node/validator_test.go b/backend/internal/node/validator_test.go index c5bb73a..6945211 100644 --- a/backend/internal/node/validator_test.go +++ b/backend/internal/node/validator_test.go @@ -85,3 +85,42 @@ func TestValidateNodeGeneratesVlessClientCredentialWhenMissing(t *testing.T) { t.Fatalf("generated VLESS credential is incomplete: %#v", users[0]) } } + +// TestValidateNodeAcceptsTuicV5MapUsers verifies that tuic-v5 with the +// wiki-standard `users: {UUID: password}` map shape is accepted. +// Pre-fix this was rejected because mapUserProtocols omitted "tuic-v5", +// causing validateObject to fall through to the nested-schema check and +// fail with "field 'users' does not accept an object". +func TestValidateNodeAcceptsTuicV5MapUsers(t *testing.T) { + err := ValidateNode(validNode("tuic-v5", `{"users":{"00000000-0000-0000-0000-000000000001":"password"},"certificate":"./server.crt","private-key":"./server.key","congestion-controller":"bbr","alpn":["h3"]}`)) + if err != nil { + t.Fatalf("expected tuic-v5 with map users to validate, got: %v", err) + } +} + +// TestValidateNodeAcceptsTuicV4TokenArray verifies that tuic-v4 with the +// wiki-standard `token: [TOKEN]` array shape is accepted. +func TestValidateNodeAcceptsTuicV4TokenArray(t *testing.T) { + err := ValidateNode(validNode("tuic-v4", `{"token":["T1","T2"],"certificate":"./server.crt","private-key":"./server.key","congestion-controller":"bbr","alpn":["h3"]}`)) + if err != nil { + t.Fatalf("expected tuic-v4 with token array to validate, got: %v", err) + } +} + +// TestValidateNodeRejectsTuicV4WithUsers verifies that tuic-v4 with a +// `users` field is rejected — v4 is token-only per wiki. +func TestValidateNodeRejectsTuicV4WithUsers(t *testing.T) { + err := ValidateNode(validNode("tuic-v4", `{"token":["T1"],"users":{"u":"p"}}`)) + if err == nil { + t.Fatalf("expected tuic-v4-with-users rejection (must not set users OR unsupported field), got: nil") + } +} + +// TestValidateNodeRejectsTuicV5WithToken verifies that tuic-v5 with a +// `token` field is rejected — v5 is users-only per wiki. +func TestValidateNodeRejectsTuicV5WithToken(t *testing.T) { + err := ValidateNode(validNode("tuic-v5", `{"users":{"u":"p"},"token":["T1"]}`)) + if err == nil { + t.Fatalf("expected tuic-v5-with-token rejection (must not set token OR unsupported field), got: nil") + } +} diff --git a/backend/internal/protocol/compile_modules.go b/backend/internal/protocol/compile_modules.go index 20c092c..3e592df 100644 --- a/backend/internal/protocol/compile_modules.go +++ b/backend/internal/protocol/compile_modules.go @@ -139,12 +139,14 @@ func (t TUICCompiler) Compile(in CompileInput) (map[string]interface{}, error) { } kind := t.Kind() if kind == "tuic-v4" { - // TUIC v4: non-empty token required (empty [] is treated as missing). - if s := firstStringToken(m["token"]); s != "" { - m["token"] = []string{s} - } else { - m["token"] = []string{randomToken()} + // TUIC v4: token array — preserve ALL non-empty tokens (wiki allows + // multi-token form: `token: [T1, T2, …]`, any one authenticates). + // Pre-fix this dropped all but the first token at compile time. + toks := allStringTokens(m["token"]) + if len(toks) == 0 { + toks = []string{randomToken()} } + m["token"] = toks delete(m, "users") } else { // TUIC v5 / generic: users map UUID→password (wiki inbound tuic-v5). @@ -265,3 +267,36 @@ func firstStringToken(tok interface{}) string { } return "" } + +// allStringTokens returns every non-empty trimmed string token from a +// listener Config `token` field. Mihomo's TUIC v4 spec accepts multiple +// tokens (`token: [T1, T2, …]`); any one authenticates the client. +// Used by TUIC v4 compiler to preserve the full array instead of dropping +// all but the first. +func allStringTokens(tok interface{}) []string { + switch v := tok.(type) { + case string: + if s := strings.TrimSpace(v); s != "" { + return []string{s} + } + case []string: + out := make([]string, 0, len(v)) + for _, s := range v { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + return out + case []interface{}: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + if s = strings.TrimSpace(s); s != "" { + out = append(out, s) + } + } + } + return out + } + return nil +} diff --git a/backend/internal/protocol/share_modules.go b/backend/internal/protocol/share_modules.go index d19587d..ad3dc64 100644 --- a/backend/internal/protocol/share_modules.go +++ b/backend/internal/protocol/share_modules.go @@ -624,14 +624,19 @@ func (t TUICCompiler) BuildShare(in ShareInput) (Share, error) { if sni == "" { sni = strings.TrimSpace(in.Node.PublicHost) } + // SNI defaults to the server host so formal (Let's Encrypt etc.) certs + // can verify. Only fall back to skipCert for panel self-signed PEMs + // (O=3m-ui marker) — NOT for any cert-without-SNI (which would break + // formal-cert listeners when the operator forgot to set SNI). + if sni == "" { + sni = host + } 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"