From 4927383faac898b19f5013cab5d9169975c8516e Mon Sep 17 00:00:00 2001 From: will Date: Tue, 30 Jun 2026 20:34:07 +0900 Subject: [PATCH] fix: compile structured custom_routes with match+action into sing-box format The panel sends structured route rules via custom_routes (not custom_route_rules). Previously these were passed raw to sing-box, causing unmarshal errors when the action field is an object. Now detect and compile them through the same path as custom_route_rules. --- internal/kernel/singbox/config.go | 84 ++++++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/internal/kernel/singbox/config.go b/internal/kernel/singbox/config.go index fe11766..ef001ca 100644 --- a/internal/kernel/singbox/config.go +++ b/internal/kernel/singbox/config.go @@ -155,8 +155,15 @@ func buildRoutes(panelRoutes []model.RouteRule, customRules []model.CustomRouteR rules = append(rules, compileCustomRouteRule(rule)...) } - // Raw custom routes remain the escape hatch, but no longer outrank structured rules. + // Raw custom routes: detect structured format (match+action) and compile them, + // otherwise pass through as raw sing-box rules. for _, cr := range custom { + if _, hasMatch := cr["match"]; hasMatch { + if parsed, ok := tryParseAsCustomRouteRule(cr); ok { + rules = append(rules, compileCustomRouteRule(parsed)...) + continue + } + } rules = append(rules, M(cr)) } @@ -309,6 +316,81 @@ func compileCustomRouteRule(rule model.CustomRouteRule) []M { return compiled } + +// tryParseAsCustomRouteRule attempts to convert a raw map (from custom_routes) +// into a structured CustomRouteRule. Returns false if the map doesn't match +// the structured format. +func tryParseAsCustomRouteRule(cr map[string]any) (model.CustomRouteRule, bool) { + var rule model.CustomRouteRule + + // Parse name + if name, ok := cr["name"].(string); ok { + rule.Name = name + } + if disabled, ok := cr["disabled"].(bool); ok { + rule.Disabled = disabled + } + + // Parse match + matchRaw, ok := cr["match"].(map[string]any) + if !ok { + return rule, false + } + rule.Match = parseRouteMatch(matchRaw) + + // Parse action + switch action := cr["action"].(type) { + case string: + rule.Action = model.RouteAction{Type: action} + case map[string]any: + if t, ok := action["type"].(string); ok { + rule.Action.Type = t + } + if target, ok := action["target"].(string); ok { + rule.Action.Target = target + } + default: + return rule, false + } + + return rule, true +} + +func parseRouteMatch(m map[string]any) model.RouteMatch { + var match model.RouteMatch + if v, ok := m["domains"].([]any); ok { + match.Domains = toStringSlice(v) + } + if v, ok := m["domain_suffixes"].([]any); ok { + match.DomainSuffixes = toStringSlice(v) + } + if v, ok := m["ip_cidrs"].([]any); ok { + match.IPCIDRs = toStringSlice(v) + } + if v, ok := m["ports"].([]any); ok { + match.Ports = toStringSlice(v) + } + if v, ok := m["networks"].([]any); ok { + match.Networks = toStringSlice(v) + } + if v, ok := m["source_cidrs"].([]any); ok { + match.SourceCIDRs = toStringSlice(v) + } + if v, ok := m["source_ports"].([]any); ok { + match.SourcePorts = toStringSlice(v) + } + return match +} + +func toStringSlice(arr []any) []string { + result := make([]string, 0, len(arr)) + for _, v := range arr { + if s, ok := v.(string); ok { + result = append(result, s) + } + } + return result +} func singboxOutboundForAction(action model.RouteAction) string { switch action.Type { case "direct":