feat(sub): opt-in dns and sniffer from remote Clash rules (#6868)

Public Mihomo templates ship rules, rule-providers and proxy-groups together
with a dns block those rules depend on (fake-ip ranges, nameserver policy).
Pasting a template inline keeps its dns, but pointing the global routing rules
at its URL dropped it, so URL users had to give up auto-refresh to keep it.

A new switch, subClashRemoteDNS (off by default), also takes the remote dns and
sniffer maps. It is opt-in because a remote dns block decides where clients send
every lookup. A malformed dns or sniffer value is skipped, not the whole template.
The component-test teardown now outlasts AntD Form's 10ms error debounce, which
otherwise fired after jsdom was gone.
This commit is contained in:
Duxxie
2026-10-24 05:21:04 +03:00
committed by GitHub
parent 3acdfd9dea
commit c3394fefed
44 changed files with 355 additions and 46 deletions
+45 -19
View File
@@ -20,15 +20,16 @@ import (
)
type SubClashService struct {
enableRouting bool
clashRules string
SubService *SubService
enableRouting bool
clashRules string
clashRemoteDNS bool
SubService *SubService
}
var errNoLegacyClashProxies = errors.New("no Clash for Windows-compatible proxies found; use the Mihomo subscription for modern proxy types")
func NewSubClashService(enableRouting bool, clashRules string, subService *SubService) *SubClashService {
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, SubService: subService}
func NewSubClashService(enableRouting bool, clashRules string, clashRemoteDNS bool, subService *SubService) *SubClashService {
return &SubClashService{enableRouting: enableRouting, clashRules: clashRules, clashRemoteDNS: clashRemoteDNS, SubService: subService}
}
func (s *SubClashService) GetClash(subId string, host string) (string, string, error) {
@@ -170,7 +171,7 @@ func (s *SubClashService) getClash(subId string, host string, legacy bool) (stri
resolved, remoteDocument, remote, resolveErr := resolveClashRoutingSource(s.clashRules)
if resolveErr == nil && strings.TrimSpace(resolved) != "" {
if remote {
if err := mergeRemoteClashRules(config, remoteDocument); err != nil {
if err := mergeRemoteClashRules(config, remoteDocument, s.clashRemoteDNS); err != nil {
return "", "", err
}
} else if err := mergeClashRulesYAML(config, resolved); err != nil {
@@ -1296,17 +1297,51 @@ 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 {
type remoteClashKeyClass int
const (
remoteClashRouteGraph remoteClashKeyClass = iota + 1
remoteClashClientConfig
)
// Client config keys steer the client's own resolver, so a remote URL may set
// them only when the admin opts in (subClashRemoteDNS).
var remoteClashKeyClasses = map[string]remoteClashKeyClass{
"proxy-groups": remoteClashRouteGraph,
"rule-providers": remoteClashRouteGraph,
"rules": remoteClashRouteGraph,
"dns": remoteClashClientConfig,
"sniffer": remoteClashClientConfig,
}
func remoteClashKeyAllowed(key string, allowClientConfig bool) bool {
switch remoteClashKeyClasses[key] {
case remoteClashRouteGraph:
return true
case remoteClashClientConfig:
return allowClientConfig
default:
return false
}
}
// mergeRemoteClashRules copies only keys listed in remoteClashKeyClasses and
// never mutates remote: cached documents are shared.
func mergeRemoteClashRules(base map[string]any, remote map[string]any, allowClientConfig bool) 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) {
if !remoteClashKeyAllowed(key, allowClientConfig) {
continue
}
if remoteClashKeyClasses[key] == remoteClashClientConfig {
// A malformed dns or sniffer must not cost the client its routing.
if _, ok := value.(map[string]any); !ok {
continue
}
}
if err := validateRemoteClashValue(key, value); err != nil {
return err
}
@@ -1382,15 +1417,6 @@ func validateRemoteClashValue(key string, value any) error {
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": {},