package transportcfg import ( "fmt" "strings" ) type SingBoxProfileInput struct { ID string Mode string Protocol string RawConfig map[string]any Typed map[string]any } type SingBoxIssue struct { Field string Severity string Code string Message string } type SingBoxProfileEvalDeps struct { NormalizeMode func(mode string) (normalized string, ok bool) CloneMapDeep func(in map[string]any) map[string]any AsString func(v any) string ProtocolSupported func(proto string) bool ParsePort func(v any) (int, bool) } func ValidateSingBoxProfile(profile SingBoxProfileInput, deps SingBoxProfileEvalDeps) ([]SingBoxIssue, []SingBoxIssue) { errs := make([]SingBoxIssue, 0) warns := make([]SingBoxIssue, 0) if strings.TrimSpace(profile.ID) == "" { errs = append(errs, SingBoxIssue{ Field: "id", Severity: "error", Code: "SINGBOX_PROFILE_ID_EMPTY", Message: "profile id is required", }) } mode, ok := normalizeProfileMode(profile.Mode, deps) if !ok { errs = append(errs, SingBoxIssue{ Field: "mode", Severity: "error", Code: "SINGBOX_PROFILE_MODE_INVALID", Message: "mode must be typed|raw", }) return errs, warns } if mode == "raw" { if len(profile.RawConfig) == 0 { errs = append(errs, SingBoxIssue{ Field: "raw_config", Severity: "error", Code: "SINGBOX_PROFILE_RAW_EMPTY", Message: "raw_config is required for raw mode", }) } if len(profile.Typed) > 0 { warns = append(warns, SingBoxIssue{ Field: "typed", Severity: "warning", Code: "SINGBOX_PROFILE_TYPED_IGNORED", Message: "typed fields are ignored in raw mode", }) } return errs, warns } proto := strings.ToLower(strings.TrimSpace(profile.Protocol)) if !isProtocolSupported(proto, deps) { errs = append(errs, SingBoxIssue{ Field: "protocol", Severity: "error", Code: "SINGBOX_PROFILE_PROTOCOL_UNSUPPORTED", Message: "typed mode supports: vless,trojan,shadowsocks,wireguard,hysteria2,tuic", }) } if len(profile.Typed) == 0 { errs = append(errs, SingBoxIssue{ Field: "typed", Severity: "error", Code: "SINGBOX_PROFILE_TYPED_EMPTY", Message: "typed config is required in typed mode", }) return errs, warns } server := strings.TrimSpace(asString(deps, profile.Typed["server"])) if server == "" { addr := strings.TrimSpace(asString(deps, profile.Typed["address"])) if addr == "" && profile.Typed["config"] == nil { errs = append(errs, SingBoxIssue{ Field: "typed.server", Severity: "error", Code: "SINGBOX_PROFILE_SERVER_REQUIRED", Message: "typed.server (or typed.address) is required", }) } } if p, ok := parsePort(deps, profile.Typed["port"]); ok { if p <= 0 || p > 65535 { errs = append(errs, SingBoxIssue{ Field: "typed.port", Severity: "error", Code: "SINGBOX_PROFILE_PORT_INVALID", Message: "typed.port must be in range 1..65535", }) } } if len(profile.RawConfig) > 0 { warns = append(warns, SingBoxIssue{ Field: "raw_config", Severity: "warning", Code: "SINGBOX_PROFILE_RAW_IGNORED", Message: "raw_config is ignored in typed mode", }) } return errs, warns } func RenderSingBoxProfileConfig(profile SingBoxProfileInput, deps SingBoxProfileEvalDeps) (map[string]any, error) { mode, ok := normalizeProfileMode(profile.Mode, deps) if !ok { return nil, fmt.Errorf("unsupported profile mode %q", profile.Mode) } if mode == "raw" { cfg := cloneMap(deps, profile.RawConfig) if len(cfg) == 0 { return nil, fmt.Errorf("raw_config is empty") } return cfg, nil } typed := cloneMap(deps, profile.Typed) if typed == nil { return nil, fmt.Errorf("typed config is empty") } if embedded, ok := typed["config"].(map[string]any); ok && len(embedded) > 0 { return cloneMap(deps, embedded), nil } outbound := cloneMap(deps, typed) if outbound == nil { outbound = map[string]any{} } proto := strings.ToLower(strings.TrimSpace(profile.Protocol)) if proto == "" { return nil, fmt.Errorf("protocol is required in typed mode") } outbound["type"] = proto tag := strings.TrimSpace(asString(deps, outbound["tag"])) if tag == "" { tag = "proxy" outbound["tag"] = tag } cfg := map[string]any{ "log": map[string]any{ "level": "warn", }, "outbounds": []any{ outbound, map[string]any{"type": "direct", "tag": "direct"}, }, "route": map[string]any{ "final": tag, "auto_detect_interface": true, }, "dns": map[string]any{ "servers": []any{ map[string]any{"type": "local", "tag": "local"}, }, "final": "local", }, } if v, ok := typed["dns"]; ok { if dnsMap, ok := v.(map[string]any); ok && len(dnsMap) > 0 { cfg["dns"] = cloneMap(deps, dnsMap) } } if v, ok := typed["route"]; ok { if routeMap, ok := v.(map[string]any); ok && len(routeMap) > 0 { cfg["route"] = cloneMap(deps, routeMap) } } if v, ok := typed["inbounds"]; ok { switch vv := v.(type) { case []any: cfg["inbounds"] = vv case map[string]any: cfg["inbounds"] = []any{vv} } } return cfg, nil } func NormalizeSingBoxRenderedConfig(config map[string]any, asStringFn func(v any) string) map[string]any { if config == nil { return nil } outboundsRaw, ok := config["outbounds"].([]any) if !ok || len(outboundsRaw) == 0 { return config } asString := asStringFn if asString == nil { asString = func(v any) string { return strings.TrimSpace(fmt.Sprint(v)) } } for i := range outboundsRaw { outbound, ok := outboundsRaw[i].(map[string]any) if !ok { continue } if strings.ToLower(strings.TrimSpace(asString(outbound["type"]))) != "vless" { continue } packetEncoding := strings.ToLower(strings.TrimSpace(asString(outbound["packet_encoding"]))) if packetEncoding == "" || packetEncoding == "none" { delete(outbound, "packet_encoding") } flow := strings.ToLower(strings.TrimSpace(asString(outbound["flow"]))) if flow == "" || flow == "none" { delete(outbound, "flow") } } config["outbounds"] = outboundsRaw return config } func normalizeProfileMode(mode string, deps SingBoxProfileEvalDeps) (string, bool) { if deps.NormalizeMode == nil { return "", false } out, ok := deps.NormalizeMode(mode) return strings.ToLower(strings.TrimSpace(out)), ok } func asString(deps SingBoxProfileEvalDeps, v any) string { if deps.AsString == nil { return "" } return deps.AsString(v) } func cloneMap(deps SingBoxProfileEvalDeps, in map[string]any) map[string]any { if deps.CloneMapDeep == nil { if in == nil { return nil } out := make(map[string]any, len(in)) for k, v := range in { out[k] = v } return out } return deps.CloneMapDeep(in) } func isProtocolSupported(proto string, deps SingBoxProfileEvalDeps) bool { if deps.ProtocolSupported == nil { return false } return deps.ProtocolSupported(proto) } func parsePort(deps SingBoxProfileEvalDeps, v any) (int, bool) { if deps.ParsePort == nil { return 0, false } return deps.ParsePort(v) }