Add remote routing URL support (#6168)

* Add remote routing URL support

* Harden remote routing refresh

* fix(sub): harden remote routing fetch and accept Mihomo src rule flag

Remote routing bytes reach the YAML/JSON parsers from goroutines that run
outside Gin's recovery, so a parser panic on crafted input would take down
the whole panel. Contain it in fetch() (a panic now degrades to a failed
refresh that keeps the last-good value and releases the in-flight slot)
and start the refresh, cache-load and startup-warm goroutines through
common.GoRecover like the other background workers.

The route-graph validator only skipped a trailing no-resolve flag, so a
valid Mihomo rule like IP-CIDR,x,DIRECT,no-resolve,src was rejected as an
unknown target; skip both option flags.

Also deduplicate the HTTPS-source classification into
common.ParseRemoteRoutingURL so the save-time validator and the resolver
can never drift (internal/sub imports internal/web/service, so the copy
existed only to avoid the import cycle), move the test-only
mergeRemoteClashRulesYAML helper into the test file, and trim oversized
comment blocks.

---------

Co-authored-by: Duxxie <yelloduxx@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
Duxxie
2026-08-18 17:02:11 +03:00
committed by GitHub
parent 3a2f9b48da
commit 380aff4d82
25 changed files with 1835 additions and 54 deletions
+250 -2
View File
@@ -1,6 +1,7 @@
package sub
import (
"errors"
"fmt"
"maps"
"strings"
@@ -98,8 +99,15 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
}
if s.enableRouting {
if err := mergeClashRulesYAML(config, s.clashRules); err != nil {
return "", "", err
resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
if resolveErr == nil && strings.TrimSpace(resolved) != "" {
if remote {
if err := mergeRemoteClashRules(config, remoteDocument); err != nil {
return "", "", err
}
} else if err := mergeClashRulesYAML(config, resolved); err != nil {
return "", "", err
}
}
}
@@ -814,6 +822,246 @@ func mergeClashRulesYAML(base map[string]any, raw string) error {
return nil
}
// mergeRemoteClashRules lets remote update only the route graph (see
// remoteClashAllowedKey) and never mutates remote: cached documents are shared.
func mergeRemoteClashRules(base map[string]any, remote map[string]any) error {
if len(remote) == 0 {
return fmt.Errorf("remote Clash routing source must be a YAML map")
}
for key, value := range remote {
if !remoteClashAllowedKey(key) {
continue
}
if err := validateRemoteClashValue(key, value); err != nil {
return err
}
switch key {
case "rules":
rules, _ := asAnySlice(value)
mergeClashRules(base, rules)
case "proxy-groups":
groups, _ := asAnySlice(value)
base["proxy-groups"] = mergeClashProxyGroups(base["proxy-groups"], groups)
default:
base[key] = value
}
}
return validateClashRouteGraph(base)
}
func validateRemoteClashValue(key string, value any) error {
switch key {
case "rules":
rules, ok := asAnySlice(value)
if !ok {
return fmt.Errorf("remote Clash rules must be a list")
}
for _, rule := range rules {
text, ok := rule.(string)
if !ok || strings.TrimSpace(text) == "" {
return fmt.Errorf("remote Clash rules must contain non-empty strings")
}
}
case "proxy-groups":
groups, ok := asAnySlice(value)
if !ok {
return fmt.Errorf("remote Clash proxy-groups must be a list")
}
seen := make(map[string]struct{}, len(groups))
for _, groupValue := range groups {
group, ok := groupValue.(map[string]any)
if !ok {
return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
}
name, nameOK := group["name"].(string)
groupType, typeOK := group["type"].(string)
if !nameOK || !typeOK || strings.TrimSpace(name) == "" || strings.TrimSpace(groupType) == "" {
return fmt.Errorf("remote Clash proxy-groups must contain named group maps with a type")
}
name = strings.TrimSpace(name)
if _, duplicate := seen[name]; duplicate {
return fmt.Errorf("remote Clash proxy-group name %q is duplicated", name)
}
seen[name] = struct{}{}
if useValue, exists := group["use"]; exists {
use, ok := asAnySlice(useValue)
if !ok || len(use) > 0 {
return fmt.Errorf("remote Clash proxy-group %q cannot use proxy-providers", name)
}
}
}
case "rule-providers":
providers, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("remote Clash rule-providers must be a map")
}
for name, provider := range providers {
if strings.TrimSpace(name) == "" {
return fmt.Errorf("remote Clash rule-provider name must not be empty")
}
if _, ok := provider.(map[string]any); !ok {
return fmt.Errorf("remote Clash rule-provider %q must be a map", name)
}
}
}
return nil
}
func remoteClashAllowedKey(key string) bool {
switch key {
case "proxy-groups", "rule-providers", "rules":
return true
default:
return false
}
}
func validateClashRouteGraph(config map[string]any) error {
known := map[string]struct{}{
"DIRECT": {}, "REJECT": {}, "REJECT-DROP": {}, "REJECT-TINYGIF": {}, "PASS": {}, "GLOBAL": {},
}
if proxies, ok := asAnySlice(config["proxies"]); ok {
for _, value := range proxies {
proxy, ok := value.(map[string]any)
if !ok {
continue
}
if name, ok := proxy["name"].(string); ok && strings.TrimSpace(name) != "" {
known[strings.TrimSpace(name)] = struct{}{}
}
}
}
groups, _ := asAnySlice(config["proxy-groups"])
for _, value := range groups {
if name := clashProxyGroupName(value); name != "" {
known[name] = struct{}{}
}
}
for _, value := range groups {
group, ok := value.(map[string]any)
if !ok {
continue
}
name := clashProxyGroupName(group)
refs, exists := group["proxies"]
if !exists {
continue
}
proxies, ok := asAnySlice(refs)
if !ok {
return fmt.Errorf("Clash proxy-group %q proxies must be a list", name)
}
for _, refValue := range proxies {
ref, ok := refValue.(string)
if !ok || strings.TrimSpace(ref) == "" {
return fmt.Errorf("Clash proxy-group %q contains an invalid proxy reference", name)
}
ref = strings.TrimSpace(ref)
if _, exists := known[ref]; !exists {
return fmt.Errorf("Clash proxy-group %q references unknown proxy or group %q", name, ref)
}
}
}
providers, _ := config["rule-providers"].(map[string]any)
for providerName, value := range providers {
provider, ok := value.(map[string]any)
if !ok {
continue
}
via, ok := provider["proxy"].(string)
if !ok || strings.TrimSpace(via) == "" {
continue
}
via = strings.TrimSpace(via)
if _, exists := known[via]; !exists {
return fmt.Errorf("Clash rule-provider %q references unknown proxy or group %q", providerName, via)
}
}
rules, _ := asAnySlice(config["rules"])
for _, value := range rules {
rule, ok := value.(string)
if !ok || strings.TrimSpace(rule) == "" {
return errors.New("Clash rules must contain non-empty strings")
}
parts := strings.Split(rule, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
if len(parts) < 2 {
return fmt.Errorf("invalid Clash rule %q", rule)
}
if strings.EqualFold(parts[0], "RULE-SET") {
if len(parts) < 3 {
return fmt.Errorf("invalid Clash RULE-SET rule %q", rule)
}
if _, exists := providers[parts[1]]; !exists {
return fmt.Errorf("Clash rule references unknown rule-provider %q", parts[1])
}
}
targetIndex := len(parts) - 1
// Mihomo IP rules may carry trailing no-resolve / src option flags.
for targetIndex >= 1 && (strings.EqualFold(parts[targetIndex], "no-resolve") || strings.EqualFold(parts[targetIndex], "src")) {
targetIndex--
}
if targetIndex < 1 {
return fmt.Errorf("invalid Clash rule target in %q", rule)
}
target := parts[targetIndex]
if _, exists := known[target]; !exists {
return fmt.Errorf("Clash rule references unknown proxy or group %q", target)
}
}
return nil
}
func mergeClashProxyGroups(baseValue any, remoteGroups []any) []any {
baseGroups, _ := asAnySlice(baseValue)
baseByName := make(map[string]any, len(baseGroups))
baseOrder := make([]string, 0, len(baseGroups))
for _, group := range baseGroups {
name := clashProxyGroupName(group)
if name == "" {
continue
}
baseByName[name] = group
baseOrder = append(baseOrder, name)
}
merged := make([]any, 0, len(remoteGroups)+len(baseGroups))
seen := make(map[string]struct{}, len(remoteGroups)+len(baseGroups))
for _, group := range remoteGroups {
name := clashProxyGroupName(group)
if name == "" {
continue
}
if _, duplicate := seen[name]; duplicate {
continue
}
seen[name] = struct{}{}
merged = append(merged, group)
}
for _, name := range baseOrder {
if _, replaced := seen[name]; replaced {
continue
}
merged = append(merged, baseByName[name])
}
return merged
}
func clashProxyGroupName(value any) string {
group, ok := value.(map[string]any)
if !ok {
return ""
}
name, _ := group["name"].(string)
return strings.TrimSpace(name)
}
func mergeClashRules(base map[string]any, customRules []any) {
if len(customRules) == 0 {
return