package node import ( "encoding/json" "fmt" "net" "reflect" "strconv" "strings" "github.com/kazeyukiro/3m-ui/backend/internal/credentials" "github.com/kazeyukiro/3m-ui/backend/internal/database/models" mihomoConfig "github.com/kazeyukiro/3m-ui/backend/internal/mihomo/config" ) func ValidateNode(l *models.Listener) error { l.Name = strings.TrimSpace(l.Name) if l.Name == "" { return fmt.Errorf("listener name cannot be empty") } proto := strings.ToLower(strings.TrimSpace(l.Protocol)) if proto == "" { proto = strings.ToLower(strings.TrimSpace(l.Type)) } if !mihomoConfig.IsMihomoListenerProtocol(proto) { return fmt.Errorf("unsupported Mihomo listener protocol: %s", proto) } l.Protocol, l.Type = proto, proto if !isValidPortString(l.Port) { return fmt.Errorf("port must be a valid port number, range (e.g. 8080-8090), or comma-separated list") } l.BindAddress = strings.TrimSpace(l.BindAddress) if l.BindAddress == "" { l.BindAddress = strings.TrimSpace(l.Listen) } if l.BindAddress == "" { l.BindAddress = "0.0.0.0" } // Strip brackets; accept IPv4 / IPv6 (including :: and 0.0.0.0). l.BindAddress = strings.Trim(l.BindAddress, "[]") if net.ParseIP(l.BindAddress) == nil { return fmt.Errorf("bind address must be a valid IPv4 or IPv6 address (got %q)", l.BindAddress) } if h := strings.TrimSpace(l.PublicHost); h != "" { // Public host may be domain or IP — normalize bracketed IPv6. l.PublicHost = strings.Trim(h, "[]") } if l.RoutingMark < 0 { return fmt.Errorf("routing-mark must be non-negative") } if err := credentials.EnsureListenerCredentials(l); err != nil { return err } cfg, err := decodeConfig(l.Config) if err != nil { return err } if err := validateSchema(proto, cfg); err != nil { return err } return validateProtocolSpecific(proto, cfg) } func isValidPortString(s string) bool { s = strings.TrimSpace(s) if s == "" { return false } if strings.Contains(s, ",") { parts := strings.Split(s, ",") for _, p := range parts { if !isValidPortString(strings.TrimSpace(p)) { return false } } return true } if strings.Contains(s, "-") { parts := strings.SplitN(s, "-", 2) if len(parts) != 2 { return false } start, err1 := strconv.Atoi(strings.TrimSpace(parts[0])) end, err2 := strconv.Atoi(strings.TrimSpace(parts[1])) if err1 != nil || err2 != nil || start < 1 || end > 65535 || start > end { return false } return true } port, err := strconv.Atoi(s) if err != nil || port < 1 || port > 65535 { return false } return true } func decodeConfig(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 validateSchema(proto string, cfg map[string]interface{}) error { schema, ok := mihomoConfig.GetMihomoListenerSchema(proto) if !ok { return fmt.Errorf("no listener schema registered for protocol %s", proto) } return validateObject(proto, "", cfg, schema.Fields, schema.NestedFields) } func validateObject(proto, prefix string, value map[string]interface{}, allowed map[string]struct{}, nested map[string]map[string]struct{}) error { for key, child := range value { if _, ok := allowed[key]; !ok { return fmt.Errorf("%s listener: unsupported field %q", proto, joinField(prefix, key)) } if childMap, ok := child.(map[string]interface{}); ok { if key == "users" && mapUserProtocols[proto] { continue } childAllowed, hasNestedSchema := nested[key] if !hasNestedSchema { return fmt.Errorf("%s listener: field %q does not accept an object", proto, joinField(prefix, key)) } childNested := make(map[string]map[string]struct{}) for nestedKey, nestedAllowed := range nested { if strings.HasPrefix(nestedKey, key+".") { childNested[strings.TrimPrefix(nestedKey, key+".")] = nestedAllowed } } if err := validateObject(proto, joinField(prefix, key), childMap, childAllowed, childNested); err != nil { return err } } } return nil } // 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 == "" { return field } return prefix + "." + field } func validateProtocolSpecific(proto string, cfg map[string]interface{}) error { switch proto { case "anytls": return validateAnyTLS(cfg) case "snell": if value, ok := numeric(cfg["version"]); ok && (value < 1 || value > 5) { return fmt.Errorf("snell version must be between 1 and 5") } case "hysteria2": if obfs, ok := cfg["obfs"].(string); ok && obfs != "" && obfs != "salamander" { return fmt.Errorf("hysteria2 obfs must be salamander") } // Official mihomo Hysteria2 inbound requires certificate/private-key; there is no allow-insecure field. if !hasCertificatePair(cfg) { return fmt.Errorf("hysteria2 listener requires certificate and private-key") } if users, ok := cfg["users"].(map[string]interface{}); !ok || len(users) == 0 { return fmt.Errorf("hysteria2 listener requires at least one user") } case "tuic", "tuic-v4", "tuic-v5": // Panel uses tuic-v4 / tuic-v5; Mihomo YAML type is always "tuic". users, token := hasNonEmpty(cfg["users"]), hasNonEmpty(cfg["token"]) switch proto { case "tuic-v4": if !token { return fmt.Errorf("tuic-v4 listener requires a non-empty token array") } if users { return fmt.Errorf("tuic-v4 listener must not set users (token-only)") } case "tuic-v5": if !users { return fmt.Errorf("tuic-v5 listener requires users map (UUID → password)") } if token { return fmt.Errorf("tuic-v5 listener must not set token (users-only)") } if values, ok := cfg["users"].(map[string]interface{}); !ok || len(values) == 0 { return fmt.Errorf("tuic-v5 listener users must be a UUID-to-password map") } default: // "tuic" legacy: exactly one of token or users if users == token { return fmt.Errorf("tuic listener must configure exactly one of users (TUIC V5) or token (TUIC V4)") } if users { if values, ok := cfg["users"].(map[string]interface{}); !ok || len(values) == 0 { return fmt.Errorf("tuic V5 listener users must be a username-to-password map") } } } case "vless", "vmess": if err := validateServerTLSModes(proto, cfg); err != nil { return err } users, ok := cfg["users"].([]interface{}) if !ok || len(users) == 0 { return fmt.Errorf("%s listener requires at least one user", proto) } for i, user := range users { if err := validateUserRow(proto, i, user, true); err != nil { return err } } case "trojan": if err := validateServerTLSModes(proto, cfg); err != nil { return err } users, ok := cfg["users"].([]interface{}) if !ok || len(users) == 0 { return fmt.Errorf("%s listener requires at least one user", proto) } for i, user := range users { if err := validateUserRow(proto, i, user, false); err != nil { return err } } case "shadowquic": users, ok := cfg["users"].([]interface{}) if !ok || len(users) == 0 { return fmt.Errorf("%s listener requires at least one user", proto) } for i, user := range users { if err := validateUserRow(proto, i, user, false); err != nil { return err } } case "mieru": if users, ok := cfg["users"].(map[string]interface{}); !ok || len(users) == 0 { return fmt.Errorf("mieru listener requires at least one user") } if transport, ok := cfg["transport"].(string); ok && transport != "" { if !strings.EqualFold(transport, "TCP") && !strings.EqualFold(transport, "UDP") { return fmt.Errorf("mieru transport must be TCP or UDP") } } case "sudoku": if aead, ok := cfg["aead-method"].(string); ok && aead != "" { switch aead { case "chacha20-poly1305", "aes-128-gcm", "none": default: return fmt.Errorf("sudoku aead-method must be chacha20-poly1305, aes-128-gcm, or none") } } if table, ok := cfg["table-type"].(string); ok && table != "" { switch table { case "prefer_ascii", "prefer_entropy", "up_ascii_down_entropy", "up_entropy_down_ascii": default: return fmt.Errorf("sudoku table-type is invalid") } } min, minOK := numeric(cfg["padding-min"]) max, maxOK := numeric(cfg["padding-max"]) if minOK && (min < 0 || min > 100) { return fmt.Errorf("sudoku padding-min must be between 0 and 100") } if maxOK && (max < 0 || max > 100) { return fmt.Errorf("sudoku padding-max must be between 0 and 100") } if minOK && maxOK && max < min { return fmt.Errorf("sudoku padding-max must be greater than or equal to padding-min") } if mode, ok := cfg["httpmask"].(map[string]interface{}); ok { if v, ok := mode["mode"].(string); ok && v != "" { switch v { case "legacy", "stream", "poll", "auto", "ws": default: return fmt.Errorf("sudoku httpmask mode is invalid") } } } case "trusttunnel": if !hasCertificatePair(cfg) { return fmt.Errorf("trusttunnel listener requires certificate and private-key") } if network, ok := cfg["network"].([]interface{}); ok { for _, raw := range network { v := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw))) if v != "tcp" && v != "udp" { return fmt.Errorf("trusttunnel network must contain only tcp or udp") } } } } return nil } func validateAnyTLS(cfg map[string]interface{}) error { cert, key := hasString(cfg["certificate"]), hasString(cfg["private-key"]) if cert != key { return fmt.Errorf("anytls listener requires certificate and private-key together") } shadow, res, jls := enabledObject(cfg["shadow-tls"]), enabledObject(cfg["res-tls"]), enabledObject(cfg["jls-config"]) secureModes := 0 if shadow { secureModes++ } if res { secureModes++ } if jls { secureModes++ } if cert { secureModes++ } if secureModes > 1 { return fmt.Errorf("anytls listener TLS modes are mutually exclusive: use certificate/private-key, shadow-tls, res-tls, or jls-config") } if !boolValue(cfg["allow-insecure"]) && secureModes == 0 { return fmt.Errorf("anytls listener requires certificate/private-key, shadow-tls, res-tls, jls-config, or allow-insecure=true") } if auth, ok := cfg["client-auth-type"].(string); ok && (auth == "verify-if-given" || auth == "require-and-verify") && !hasString(cfg["client-auth-cert"]) { return fmt.Errorf("anytls listener client-auth-cert is required for %s", auth) } users, ok := cfg["users"].(map[string]interface{}) if !ok || len(users) == 0 { return fmt.Errorf("anytls listener requires at least one user") } for username, password := range users { if strings.TrimSpace(username) == "" || !hasString(password) { return fmt.Errorf("anytls listener users must contain non-empty username/password pairs") } } return nil } func enabledObject(v interface{}) bool { m, ok := v.(map[string]interface{}) return ok && boolValue(m["enable"]) } func validateUserRow(proto string, index int, raw interface{}, uuidMode bool) error { row, ok := raw.(map[string]interface{}) if !ok { return fmt.Errorf("%s listener users[%d] must be an object", proto, index) } // Protocol-specific field bans with explicit messages expected by tests // and matching official Mihomo listener schemas. if proto == "vmess" { if _, ok := row["flow"]; ok { return fmt.Errorf("vmess listener users[%d] does not support flow", index) } } if proto == "vless" { if _, ok := row["alterId"]; ok { return fmt.Errorf("vless listener users[%d] does not support alterId", index) } } allowed := map[string]struct{}{"username": {}} if uuidMode { allowed["uuid"] = struct{}{} if proto == "vmess" { allowed["alterId"] = struct{}{} } else { allowed["flow"] = struct{}{} } } else { allowed["password"] = struct{}{} } for key := range row { if _, ok := allowed[key]; !ok { return fmt.Errorf("%s listener users[%d]: unsupported field %q", proto, index, key) } } // Official Mihomo VLESS/VMess listener users are keyed by uuid (and optional // flow/alterId). username is panel/bookkeeping only — not required by the core. if uuidMode { if !hasString(row["uuid"]) { return fmt.Errorf("%s listener users[%d] requires uuid", proto, index) } return nil } if !hasString(row["username"]) { return fmt.Errorf("%s listener users[%d] requires username", proto, index) } if !hasString(row["password"]) { return fmt.Errorf("%s listener users[%d] requires password", proto, index) } return nil } // validateServerTLSModes enforces official Mihomo listener TLS exclusivity: // certificate/private-key must appear together, and must not be combined with // reality-config (or other exclusive TLS modes when present). func validateServerTLSModes(proto string, cfg map[string]interface{}) error { cert, key := hasString(cfg["certificate"]), hasString(cfg["private-key"]) if cert != key { return fmt.Errorf("%s listener requires certificate and private-key together", proto) } if cert { if _, hasReality := cfg["reality-config"]; hasReality { return fmt.Errorf("%s listener mutually exclusive TLS modes: certificate/private-key cannot be used with reality-config", proto) } if _, hasMirror := cfg["tlsmirror-config"]; hasMirror { return fmt.Errorf("%s listener mutually exclusive TLS modes: certificate/private-key cannot be used with tlsmirror-config", proto) } } return nil } func hasCertificatePair(cfg map[string]interface{}) bool { return hasString(cfg["certificate"]) && hasString(cfg["private-key"]) } func hasString(value interface{}) bool { s, ok := value.(string) return ok && strings.TrimSpace(s) != "" } func hasNonEmpty(value interface{}) bool { if value == nil { return false } if s, ok := value.(string); ok { return strings.TrimSpace(s) != "" } v := reflect.ValueOf(value) switch v.Kind() { case reflect.Map: return v.Len() > 0 case reflect.Slice: if v.Len() == 0 { return false } // token: [""] must not count as configured allEmpty := true for i := 0; i < v.Len(); i++ { elem := v.Index(i).Interface() if s, ok := elem.(string); ok { if strings.TrimSpace(s) != "" { return true } } else if elem != nil { allEmpty = false } } return !allEmpty && v.Len() > 0 default: return true } } func boolValue(v interface{}) bool { b, _ := v.(bool) return b } func numeric(v interface{}) (float64, bool) { n, ok := v.(float64) return n, ok }