fix: compile structured custom_routes with match+action into sing-box format
CI / Test (push) Has been cancelled
CI / Build (amd64, linux) (push) Has been cancelled
CI / Build (arm64, linux) (push) Has been cancelled
CI / Build & Push Docker Image (push) Has been cancelled
CI / Release (push) Has been cancelled

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.
This commit is contained in:
2026-06-30 20:34:07 +09:00
parent fd26f8de88
commit 4927383faa
+83 -1
View File
@@ -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":