From c3394fefeddb0bf1bd339a19aba4348a55f93588 Mon Sep 17 00:00:00 2001 From: Duxxie <104713086+yelloduxx@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:21:04 +0300 Subject: [PATCH] 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. --- docs/public/openapi.json | 8 + frontend/public/openapi.json | 8 + frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 8 + frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/models/setting.ts | 1 + .../pages/settings/SubscriptionGeneralTab.tsx | 10 ++ frontend/src/schemas/setting.ts | 1 + frontend/src/test/setup.components.ts | 6 + .../test/subscription-general-tab.test.tsx | 23 +++ .../sub/clash_external_shadowsocks_test.go | 2 +- internal/sub/clash_external_vless_test.go | 2 +- internal/sub/clash_info_node_test.go | 8 +- internal/sub/clash_remote_dns_test.go | 162 ++++++++++++++++++ internal/sub/clash_service.go | 64 +++++-- internal/sub/clash_service_test.go | 2 +- internal/sub/controller.go | 7 +- internal/sub/external_config_test.go | 2 +- internal/sub/external_only_sub_test.go | 2 +- internal/sub/host_sub_test.go | 8 +- internal/sub/json_flow_gate_test.go | 2 +- internal/sub/remote_routing.go | 4 +- internal/sub/remote_routing_test.go | 12 +- internal/sub/service_exclude_from_sub_test.go | 2 +- internal/sub/sub.go | 6 + internal/sub/sub_panic_test.go | 4 +- internal/sub/vless_route_sub_test.go | 2 +- internal/web/entity/entity.go | 1 + internal/web/service/setting.go | 5 + internal/web/service/setting_clash_test.go | 7 + internal/web/translation/ar-EG.json | 2 + internal/web/translation/en-US.json | 2 + internal/web/translation/es-ES.json | 2 + internal/web/translation/fa-IR.json | 2 + internal/web/translation/id-ID.json | 2 + internal/web/translation/ja-JP.json | 2 + internal/web/translation/pt-BR.json | 2 + internal/web/translation/ru-RU.json | 2 + internal/web/translation/tr-TR.json | 2 + internal/web/translation/uk-UA.json | 2 + internal/web/translation/vi-VN.json | 2 + internal/web/translation/zh-CN.json | 2 + internal/web/translation/zh-TW.json | 2 + 44 files changed, 355 insertions(+), 46 deletions(-) create mode 100644 internal/sub/clash_remote_dns_test.go diff --git a/docs/public/openapi.json b/docs/public/openapi.json index d36661bd5..c151d54bf 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -242,6 +242,9 @@ "subClashPath": { "type": "string" }, + "subClashRemoteDNS": { + "type": "boolean" + }, "subClashRules": { "type": "string" }, @@ -662,6 +665,7 @@ "subClashEnable", "subClashEnableRouting", "subClashPath", + "subClashRemoteDNS", "subClashRules", "subClashURI", "subClashUserAgentRegex", @@ -1018,6 +1022,9 @@ "subClashPath": { "type": "string" }, + "subClashRemoteDNS": { + "type": "boolean" + }, "subClashRules": { "type": "string" }, @@ -1446,6 +1453,7 @@ "subClashEnable", "subClashEnableRouting", "subClashPath", + "subClashRemoteDNS", "subClashRules", "subClashURI", "subClashUserAgentRegex", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index d36661bd5..c151d54bf 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -242,6 +242,9 @@ "subClashPath": { "type": "string" }, + "subClashRemoteDNS": { + "type": "boolean" + }, "subClashRules": { "type": "string" }, @@ -662,6 +665,7 @@ "subClashEnable", "subClashEnableRouting", "subClashPath", + "subClashRemoteDNS", "subClashRules", "subClashURI", "subClashUserAgentRegex", @@ -1018,6 +1022,9 @@ "subClashPath": { "type": "string" }, + "subClashRemoteDNS": { + "type": "boolean" + }, "subClashRules": { "type": "string" }, @@ -1446,6 +1453,7 @@ "subClashEnable", "subClashEnableRouting", "subClashPath", + "subClashRemoteDNS", "subClashRules", "subClashURI", "subClashUserAgentRegex", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index ce0ef8676..116b6a1c8 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -65,6 +65,7 @@ export const EXAMPLES: Record = { "subClashEnable": false, "subClashEnableRouting": false, "subClashPath": "", + "subClashRemoteDNS": false, "subClashRules": "", "subClashURI": "", "subClashUserAgentRegex": "", @@ -252,6 +253,7 @@ export const EXAMPLES: Record = { "subClashEnable": false, "subClashEnableRouting": false, "subClashPath": "", + "subClashRemoteDNS": false, "subClashRules": "", "subClashURI": "", "subClashUserAgentRegex": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 6208b5d3f..421089bce 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -216,6 +216,9 @@ export const SCHEMAS: Record = { "subClashPath": { "type": "string" }, + "subClashRemoteDNS": { + "type": "boolean" + }, "subClashRules": { "type": "string" }, @@ -636,6 +639,7 @@ export const SCHEMAS: Record = { "subClashEnable", "subClashEnableRouting", "subClashPath", + "subClashRemoteDNS", "subClashRules", "subClashURI", "subClashUserAgentRegex", @@ -992,6 +996,9 @@ export const SCHEMAS: Record = { "subClashPath": { "type": "string" }, + "subClashRemoteDNS": { + "type": "boolean" + }, "subClashRules": { "type": "string" }, @@ -1420,6 +1427,7 @@ export const SCHEMAS: Record = { "subClashEnable", "subClashEnableRouting", "subClashPath", + "subClashRemoteDNS", "subClashRules", "subClashURI", "subClashUserAgentRegex", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 86334f324..1e1249692 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -73,6 +73,7 @@ export interface AllSetting { subClashEnable: boolean; subClashEnableRouting: boolean; subClashPath: string; + subClashRemoteDNS: boolean; subClashRules: string; subClashURI: string; subClashUserAgentRegex: string; @@ -261,6 +262,7 @@ export interface AllSettingView { subClashEnable: boolean; subClashEnableRouting: boolean; subClashPath: string; + subClashRemoteDNS: boolean; subClashRules: string; subClashURI: string; subClashUserAgentRegex: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 744b93c06..1578fbe97 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -89,6 +89,7 @@ export const AllSettingSchema = z.object({ subClashEnable: z.boolean(), subClashEnableRouting: z.boolean(), subClashPath: z.string(), + subClashRemoteDNS: z.boolean(), subClashRules: z.string(), subClashURI: z.string(), subClashUserAgentRegex: z.string(), @@ -278,6 +279,7 @@ export const AllSettingViewSchema = z.object({ subClashEnable: z.boolean(), subClashEnableRouting: z.boolean(), subClashPath: z.string(), + subClashRemoteDNS: z.boolean(), subClashRules: z.string(), subClashURI: z.string(), subClashUserAgentRegex: z.string(), diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index 6d7222901..df4fc54c7 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -74,6 +74,7 @@ export class AllSetting { subClashURI = ''; subClashEnableRouting = false; subClashRules = ''; + subClashRemoteDNS = false; subJsonMux = ''; subJsonRules = ''; subJsonRoutingRules = ''; diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx index ff685efd9..55a15b16a 100644 --- a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx +++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx @@ -459,6 +459,16 @@ export default function SubscriptionGeneralTab({ onChange={(e) => updateSetting({ subClashRules: e.target.value })} /> + + updateSetting({ subClashRemoteDNS: v })} + /> + ), }, diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts index 7c42d4f72..fb21f2337 100644 --- a/frontend/src/schemas/setting.ts +++ b/frontend/src/schemas/setting.ts @@ -80,6 +80,7 @@ export const AllSettingSchema = z subClashURI: z.string().optional(), subClashEnableRouting: z.boolean().optional(), subClashRules: z.string().optional(), + subClashRemoteDNS: z.boolean().optional(), subJsonMux: z.string().optional(), subJsonRules: z.string().optional(), subJsonRoutingRules: z.string().optional(), diff --git a/frontend/src/test/setup.components.ts b/frontend/src/test/setup.components.ts index 563c01fa9..6274172e6 100644 --- a/frontend/src/test/setup.components.ts +++ b/frontend/src/test/setup.components.ts @@ -95,6 +95,12 @@ afterEach(async () => { for (let i = 0; i < 3; i += 1) { await new Promise((resolve) => setTimeout(resolve, 0)); } + /* + * AntD Form debounces clearing its error list by 10ms (useDebounce via + * rc-util useDelayState), and that timer survives unmount. Wait past it so + * it fires while `window` still exists. + */ + await new Promise((resolve) => setTimeout(resolve, 20)); }); import { HttpUtil, Msg } from '@/utils'; diff --git a/frontend/src/test/subscription-general-tab.test.tsx b/frontend/src/test/subscription-general-tab.test.tsx index a712bb677..6e0d7fb6a 100644 --- a/frontend/src/test/subscription-general-tab.test.tsx +++ b/frontend/src/test/subscription-general-tab.test.tsx @@ -167,6 +167,29 @@ describe('SubscriptionGeneralTab', () => { }, ); + it.each([false, true])( + 'updates the Clash remote DNS opt-in, not the routing switch, when stored as %s', + (enabled) => { + const updateSetting = vi.fn(); + + renderWithProviders( + + + , + ); + + fireEvent.click(screen.getByRole('tab', { name: /Clash/ })); + const remoteDNSSwitch = screen.getByRole('switch', { name: 'Use DNS from remote rules' }); + expect(remoteDNSSwitch.getAttribute('aria-checked')).toBe(String(enabled)); + fireEvent.click(remoteDNSSwitch); + + expect(updateSetting).toHaveBeenCalledExactlyOnceWith({ subClashRemoteDNS: !enabled }); + }, + ); + it('shows the Happ link gate from the QR settings deep link without enabling generation', () => { const updateSetting = vi.fn(); diff --git a/internal/sub/clash_external_shadowsocks_test.go b/internal/sub/clash_external_shadowsocks_test.go index 81356fee9..0fd5f02f7 100644 --- a/internal/sub/clash_external_shadowsocks_test.go +++ b/internal/sub/clash_external_shadowsocks_test.go @@ -39,7 +39,7 @@ func TestClashExternalShadowsocksMatchesInboundPath(t *testing.T) { }, } - svc := NewSubClashService(false, "", &SubService{}) + svc := NewSubClashService(false, "", false, &SubService{}) client := model.Client{Password: "clientpw", Email: "user"} for _, tc := range tests { diff --git a/internal/sub/clash_external_vless_test.go b/internal/sub/clash_external_vless_test.go index bbfd8c278..ebc756a89 100644 --- a/internal/sub/clash_external_vless_test.go +++ b/internal/sub/clash_external_vless_test.go @@ -16,7 +16,7 @@ import ( const clashExternalVlessLink = "vless://22222222-2222-4222-8222-222222222222@198.51.100.9:443?type=tcp&security=reality&sni=example.com&pbk=test-public-key&sid=ab12&fp=chrome&flow=xtls-rprx-vision" func TestClashExternalVlessEncryption(t *testing.T) { - svc := NewSubClashService(false, "", &SubService{}) + svc := NewSubClashService(false, "", false, &SubService{}) for _, tc := range []struct { name string query string diff --git a/internal/sub/clash_info_node_test.go b/internal/sub/clash_info_node_test.go index e27e4b392..06796a9d4 100644 --- a/internal/sub/clash_info_node_test.go +++ b/internal/sub/clash_info_node_test.go @@ -56,7 +56,7 @@ func TestSubClash_InfoNode_Active(t *testing.T) { sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}") sub.subInfoNodeEnable = true - clash := NewSubClashService(false, "", sub) + clash := NewSubClashService(false, "", false, sub) out, _, err := clash.GetClash("sub-clash", "sub.example.com") if err != nil { @@ -115,7 +115,7 @@ func TestSubClash_InfoNode_Expired(t *testing.T) { sub := NewSubService("{{INBOUND}}") sub.subInfoNodeEnable = true sub.subExpiredTemplate = service.DefaultSubExpiredTemplate - clash := NewSubClashService(false, "", sub) + clash := NewSubClashService(false, "", false, sub) out, _, err := clash.GetClash("sub-clash-exp", "sub.example.com") if err != nil { @@ -178,7 +178,7 @@ func TestSubClash_InfoNode_Depleted(t *testing.T) { sub := NewSubService("{{INBOUND}}") sub.subInfoNodeEnable = true sub.subTrafficDepletedTemplate = service.DefaultSubTrafficDepletedTemplate - clash := NewSubClashService(false, "", sub) + clash := NewSubClashService(false, "", false, sub) out, _, err := clash.GetClash("sub-clash-dep", "sub.example.com") if err != nil { @@ -225,7 +225,7 @@ func TestSubClash_InfoNode_ProxyGroupOrder_DoesNotDefaultToDummy(t *testing.T) { sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}|{{STATUS}}") sub.subInfoNodeEnable = true - clash := NewSubClashService(false, "", sub) + clash := NewSubClashService(false, "", false, sub) out, _, err := clash.GetClash("sub-clash", "sub.example.com") if err != nil { diff --git a/internal/sub/clash_remote_dns_test.go b/internal/sub/clash_remote_dns_test.go new file mode 100644 index 000000000..0899ec417 --- /dev/null +++ b/internal/sub/clash_remote_dns_test.go @@ -0,0 +1,162 @@ +package sub + +import ( + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/gin-gonic/gin" + yaml "github.com/goccy/go-yaml" +) + +const remoteClashTemplateWithDNS = ` +dns: + enable: true + nameserver: + - https://8.8.8.8/dns-query +sniffer: + enable: true +rules: + - DOMAIN,remote.example,DIRECT + - MATCH,PROXY +` + +var remoteClashTemplateDNS = map[string]any{ + "enable": true, + "nameserver": []any{"https://8.8.8.8/dns-query"}, +} + +func remoteClashTestBase() map[string]any { + return map[string]any{ + "proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}}, + "proxy-groups": []map[string]any{{ + "name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"}, + }}, + "rules": []string{"MATCH,PROXY"}, + } +} + +func TestMergeRemoteClashRulesClientConfigIsOptIn(t *testing.T) { + tests := []struct { + name string + allowClientConfig bool + wantDNS any + wantSniffer any + }{ + {name: "off drops dns and sniffer", allowClientConfig: false}, + { + name: "on copies dns and sniffer", + allowClientConfig: true, + wantDNS: remoteClashTemplateDNS, + wantSniffer: map[string]any{"enable": true}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + base := remoteClashTestBase() + if err := mergeRemoteClashRulesYAML(base, remoteClashTemplateWithDNS, tt.allowClientConfig); err != nil { + t.Fatalf("merge: %v", err) + } + if !reflect.DeepEqual(base["dns"], tt.wantDNS) { + t.Fatalf("dns = %#v, want %#v", base["dns"], tt.wantDNS) + } + if rules, _ := asAnySlice(base["rules"]); len(rules) == 0 || rules[0] != "DOMAIN,remote.example,DIRECT" { + t.Fatalf("rules = %#v, want remote rules applied", base["rules"]) + } + if !reflect.DeepEqual(base["sniffer"], tt.wantSniffer) { + t.Fatalf("sniffer = %#v, want %#v", base["sniffer"], tt.wantSniffer) + } + }) + } +} + +func TestRemoteClashMalformedClientConfigIsSkipped(t *testing.T) { + tests := []struct { + name string + remote string + }{ + {name: "scalar dns", remote: "dns: 8.8.8.8\nrules:\n - DOMAIN,remote.example,DIRECT\n - MATCH,PROXY\n"}, + {name: "list dns", remote: "dns:\n - 8.8.8.8\nrules:\n - DOMAIN,remote.example,DIRECT\n - MATCH,PROXY\n"}, + {name: "null dns", remote: "dns:\nrules:\n - DOMAIN,remote.example,DIRECT\n - MATCH,PROXY\n"}, + {name: "scalar sniffer", remote: "sniffer: true\nrules:\n - DOMAIN,remote.example,DIRECT\n - MATCH,PROXY\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, _, err := normalizeClashRouting([]byte(tt.remote)); err != nil { + t.Fatalf("fetch-time err=%v, want nil", err) + } + base := remoteClashTestBase() + if err := mergeRemoteClashRulesYAML(base, tt.remote, true); err != nil { + t.Fatalf("merge err=%v, want nil", err) + } + if _, ok := base["dns"]; ok { + t.Fatalf("dns = %#v, want absent", base["dns"]) + } + if _, ok := base["sniffer"]; ok { + t.Fatalf("sniffer = %#v, want absent", base["sniffer"]) + } + if rules, _ := asAnySlice(base["rules"]); len(rules) == 0 || rules[0] != "DOMAIN,remote.example,DIRECT" { + t.Fatalf("rules = %#v, want remote rules applied", base["rules"]) + } + }) + } +} + +func TestNormalizeClashRoutingRequiresRouteGraphKey(t *testing.T) { + _, _, err := normalizeClashRouting([]byte("dns:\n enable: true\nsniffer:\n enable: true\n")) + if err == nil || err.Error() != "Clash routing response has no supported routing keys" { + t.Fatalf("err=%v, want no supported routing keys", err) + } +} + +func TestClashSubscriptionRemoteDNSFollowsSetting(t *testing.T) { + seedSubDB(t) + seedSubInbound(t, "s1", "rdns", 4601, 1, wsTLSStream) + gin.SetMode(gin.TestMode) + + oldResolver := routingSourceResolver + t.Cleanup(func() { routingSourceResolver = oldResolver }) + routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) { + return remoteRoutingResponse(http.StatusOK, remoteClashTemplateWithDNS), nil + }), false) + const source = "https://example.com/mihomo/default.yaml" + primeRemoteRouting(t, routingSourceResolver, remoteRoutingClash, source) + + tests := []struct { + name string + remoteDNS bool + wantDNS any + }{ + {name: "off keeps client dns", remoteDNS: false}, + {name: "on takes remote dns", remoteDNS: true, wantDNS: remoteClashTemplateDNS}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := gin.New() + NewSUBController( + router.Group("/"), + WithSUBClashEnabled(true), + WithSUBClashEnableRouting(true), + WithSUBClashRules(source), + WithSUBClashRemoteDNS(tt.remoteDNS), + ) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, httptest.NewRequest(http.MethodGet, "http://sub.example.com/clash/s1", nil)) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String()) + } + var config map[string]any + if err := yaml.Unmarshal(resp.Body.Bytes(), &config); err != nil { + t.Fatalf("decode clash yaml: %v\n%s", err, resp.Body.String()) + } + if !reflect.DeepEqual(config["dns"], tt.wantDNS) { + t.Fatalf("dns = %#v, want %#v\n%s", config["dns"], tt.wantDNS, resp.Body.String()) + } + if !strings.Contains(resp.Body.String(), "DOMAIN,remote.example,DIRECT") { + t.Fatalf("remote rules were not applied:\n%s", resp.Body.String()) + } + }) + } +} diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index 4629b68a2..d2e02c8a0 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -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": {}, diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index a8b025712..e9716835a 100644 --- a/internal/sub/clash_service_test.go +++ b/internal/sub/clash_service_test.go @@ -167,7 +167,7 @@ func TestBuildProxy_VLESSRealityFieldsForClash(t *testing.T) { } func TestClashRealityMLKEMAcrossSources(t *testing.T) { - svc := NewSubClashService(false, "", &SubService{}) + svc := NewSubClashService(false, "", false, &SubService{}) for _, security := range []string{"reality", "tls", "none"} { for _, fingerprint := range []string{"", "chrome", "firefox"} { t.Run(security+"/"+fingerprint, func(t *testing.T) { diff --git a/internal/sub/controller.go b/internal/sub/controller.go index b25725fa4..79b3a5b11 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -115,6 +115,7 @@ type subControllerConfig struct { subJsonObservatory string subClashEnableRouting bool subClashRules string + subClashRemoteDNS bool subTitle string subSupportURL string @@ -217,6 +218,10 @@ func WithSUBClashRules(value string) SUBControllerOption { return func(config *subControllerConfig) { config.subClashRules = value } } +func WithSUBClashRemoteDNS(value bool) SUBControllerOption { + return func(config *subControllerConfig) { config.subClashRemoteDNS = value } +} + func WithSUBTitle(value string) SUBControllerOption { return func(config *subControllerConfig) { config.subTitle = value } } @@ -319,7 +324,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo subService: sub, subJsonService: subJsonSvc, - subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, sub), + subClashService: NewSubClashService(config.subClashEnableRouting, config.subClashRules, config.subClashRemoteDNS, sub), subTemplateCache: map[string]*cachedSubTemplate{}, } diff --git a/internal/sub/external_config_test.go b/internal/sub/external_config_test.go index c42be14b6..e41cde482 100644 --- a/internal/sub/external_config_test.go +++ b/internal/sub/external_config_test.go @@ -155,7 +155,7 @@ func TestLinkDisplayName(t *testing.T) { func TestClashProxyFromExternalTrojanReality(t *testing.T) { link := "trojan://provider-pass@37.27.201.56:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv" - svc := NewSubClashService(false, "", NewSubService("")) + svc := NewSubClashService(false, "", false, NewSubService("")) proxy := svc.clashProxyFromExternal(link, "DE-Provider") if proxy == nil { t.Fatal("expected a clash proxy, got nil") diff --git a/internal/sub/external_only_sub_test.go b/internal/sub/external_only_sub_test.go index c8146a9fd..601c3cdba 100644 --- a/internal/sub/external_only_sub_test.go +++ b/internal/sub/external_only_sub_test.go @@ -56,7 +56,7 @@ func TestJsonAndClashServeExternalLinkOnlySub(t *testing.T) { t.Fatalf("standards-compliant GetJson profile count = %d, want 1", len(configs)) } - clashOut, _, err := NewSubClashService(false, "", base).GetClash("ext-only", "sub.example.com") + clashOut, _, err := NewSubClashService(false, "", false, base).GetClash("ext-only", "sub.example.com") if err != nil { t.Fatalf("GetClash err = %v", err) } diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 6cd02ba0b..75d01961c 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -260,7 +260,7 @@ func TestSub_HostAllowInsecure(t *testing.T) { t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n")) } - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(false, "", false, NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) @@ -281,7 +281,7 @@ func TestSub_HostHeaderReachesClashAndJson(t *testing.T) { HostHeader: "cdn.example.com", Path: "/ws-path", }) - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(false, "", false, NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) @@ -507,7 +507,7 @@ func TestSub_HostRealitySniJSONAndClash(t *testing.T) { t.Fatalf("server-side serverNames %v leaked into the json reality client:\n%s", names, out) } - yaml, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("s1", "req.example.com") + yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) } @@ -551,7 +551,7 @@ func TestSub_ExcludeFromSubTypes(t *testing.T) { t.Fatalf("host not excluded from raw should appear in GetSubs") } - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(false, "", false, NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) diff --git a/internal/sub/json_flow_gate_test.go b/internal/sub/json_flow_gate_test.go index 93f4cf53e..75d831dc9 100644 --- a/internal/sub/json_flow_gate_test.go +++ b/internal/sub/json_flow_gate_test.go @@ -51,7 +51,7 @@ func TestSub_JSONStripsFlowOnUnsupportedTransport(t *testing.T) { t.Fatalf("raw link must not carry a flow on ws+tls: %s", joined) } - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(false, "", false, NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) diff --git a/internal/sub/remote_routing.go b/internal/sub/remote_routing.go index 42728b3f4..90113e4ed 100644 --- a/internal/sub/remote_routing.go +++ b/internal/sub/remote_routing.go @@ -450,7 +450,7 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) { } hasSupportedKey := false for key := range document { - if remoteClashAllowedKey(key) { + if remoteClashKeyClasses[key] == remoteClashRouteGraph { hasSupportedKey = true break } @@ -465,7 +465,7 @@ func normalizeClashRouting(body []byte) (string, map[string]any, error) { }}, "rules": []string{"MATCH,PROXY"}, } - if err := mergeRemoteClashRules(base, document); err != nil { + if err := mergeRemoteClashRules(base, document, false); err != nil { return "", nil, fmt.Errorf("invalid remote Clash routing schema: %w", err) } return text, document, nil diff --git a/internal/sub/remote_routing_test.go b/internal/sub/remote_routing_test.go index 2182620ba..a22ad7566 100644 --- a/internal/sub/remote_routing_test.go +++ b/internal/sub/remote_routing_test.go @@ -20,12 +20,12 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest" ) -func mergeRemoteClashRulesYAML(base map[string]any, raw string) error { +func mergeRemoteClashRulesYAML(base map[string]any, raw string, allowClientConfig bool) error { var remote map[string]any if err := yaml.Unmarshal([]byte(strings.TrimSpace(raw)), &remote); err != nil { return err } - return mergeRemoteClashRules(base, remote) + return mergeRemoteClashRules(base, remote, allowClientConfig) } type remoteRoutingRoundTripper func(*http.Request) (*http.Response, error) @@ -475,7 +475,7 @@ rules: - RULE-SET,roscom,PROXY - MATCH,PROXY ` - if err := mergeRemoteClashRulesYAML(base, remote); err != nil { + if err := mergeRemoteClashRulesYAML(base, remote, false); err != nil { t.Fatalf("merge: %v", err) } proxies, ok := base["proxies"].([]map[string]any) @@ -520,7 +520,7 @@ func TestMergeRemoteClashRulesKeepsBaseProxyGroupWhenRemoteOmitsIt(t *testing.T) proxies: [PROXY] rules: - MATCH,PROXY -`); err != nil { +`, false); err != nil { t.Fatalf("merge: %v", err) } groups, ok := asAnySlice(base["proxy-groups"]) @@ -692,7 +692,7 @@ func TestRemoteClashRouteGraphValidation(t *testing.T) { }}, "rules": []string{"MATCH,PROXY"}, } - err := mergeRemoteClashRulesYAML(base, tt.remote) + err := mergeRemoteClashRulesYAML(base, tt.remote, false) if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("err=%v, want %q", err, tt.wantErr) } @@ -741,7 +741,7 @@ rules: }}, "rules": []string{"MATCH,PROXY"}, } - if err := mergeRemoteClashRules(base, entry.Clash); err != nil { + if err := mergeRemoteClashRules(base, entry.Clash, false); err != nil { t.Fatalf("merge cached document: %v", err) } if requests.Load() != 1 { diff --git a/internal/sub/service_exclude_from_sub_test.go b/internal/sub/service_exclude_from_sub_test.go index 42c9def16..4f0d2565c 100644 --- a/internal/sub/service_exclude_from_sub_test.go +++ b/internal/sub/service_exclude_from_sub_test.go @@ -54,7 +54,7 @@ func TestExcludedInboundHidesLinksButKeepsUsage(t *testing.T) { }) t.Run("clash", func(t *testing.T) { - out, header, err := NewSubClashService(false, "", NewSubService("")).GetClash("sub-excl", "req.example.com") + out, header, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("sub-excl", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) } diff --git a/internal/sub/sub.go b/internal/sub/sub.go index 6bcb55005..d6ff2edf4 100644 --- a/internal/sub/sub.go +++ b/internal/sub/sub.go @@ -180,6 +180,11 @@ func (s *Server) initRouter() (*gin.Engine, error) { SubClashRules = "" } + SubClashRemoteDNS, err := s.settingService.GetSubClashRemoteDNS() + if err != nil { + SubClashRemoteDNS = false + } + SubTitle, err := s.settingService.GetSubTitle() if err != nil { SubTitle = "" @@ -361,6 +366,7 @@ func (s *Server) initRouter() (*gin.Engine, error) { WithSUBJsonObservatory(SubJsonObservatory), WithSUBClashEnableRouting(SubClashEnableRouting), WithSUBClashRules(SubClashRules), + WithSUBClashRemoteDNS(SubClashRemoteDNS), WithSUBTitle(SubTitle), WithSUBSupportURL(SubSupportUrl), WithSUBProfileURL(SubProfileUrl), diff --git a/internal/sub/sub_panic_test.go b/internal/sub/sub_panic_test.go index 26fb99b5c..04947888c 100644 --- a/internal/sub/sub_panic_test.go +++ b/internal/sub/sub_panic_test.go @@ -99,7 +99,7 @@ func TestGetClashEmitsPinnedCertSha256(t *testing.T) { stream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"pin.sni","settings":{"pinnedPeerCertSha256":["` + pin + `"]}}}` seedSubInbound(t, "pin1", "pin", 46300, 1, stream) - out, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("pin1", "sub.example.com") + out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("pin1", "sub.example.com") if err != nil { t.Fatalf("GetClash: %v", err) } @@ -122,7 +122,7 @@ func TestJsonAndClashTolerateExternalProxyMissingPort(t *testing.T) { t.Fatal("GetJson returned empty for an externalProxy entry missing port") } - clashOut, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("extp1", "sub.example.com") + clashOut, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("extp1", "sub.example.com") if err != nil { t.Fatalf("GetClash: %v", err) } diff --git a/internal/sub/vless_route_sub_test.go b/internal/sub/vless_route_sub_test.go index 272c726e6..803b1c1c6 100644 --- a/internal/sub/vless_route_sub_test.go +++ b/internal/sub/vless_route_sub_test.go @@ -69,7 +69,7 @@ func TestSub_HostVlessRoute_Clash(t *testing.T) { ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream) seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"}) - clash := NewSubClashService(false, "", NewSubService("")) + clash := NewSubClashService(false, "", false, NewSubService("")) yaml, _, err := clash.GetClash("s1", "req.example.com") if err != nil { t.Fatalf("GetClash: %v", err) diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 3b2dbf612..3437a86ad 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -122,6 +122,7 @@ type AllSetting struct { SubClashURI string `json:"subClashURI" form:"subClashURI"` SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` SubClashRules string `json:"subClashRules" form:"subClashRules"` + SubClashRemoteDNS bool `json:"subClashRemoteDNS" form:"subClashRemoteDNS"` SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonRoutingRules string `json:"subJsonRoutingRules" form:"subJsonRoutingRules"` diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index a2b02b02b..c309726c5 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -186,6 +186,7 @@ var defaultValueMap = map[string]string{ "subClashURI": "", "subClashEnableRouting": "false", "subClashRules": "", + "subClashRemoteDNS": "false", "subJsonMux": "", "subJsonRules": "", "subJsonRoutingRules": "", @@ -1241,6 +1242,10 @@ func (s *SettingService) GetSubClashRules() (string, error) { return s.getString("subClashRules") } +func (s *SettingService) GetSubClashRemoteDNS() (bool, error) { + return s.getBool("subClashRemoteDNS") +} + func (s *SettingService) GetSubJsonMux() (string, error) { return s.getString("subJsonMux") } diff --git a/internal/web/service/setting_clash_test.go b/internal/web/service/setting_clash_test.go index 49d4514c9..b07916686 100644 --- a/internal/web/service/setting_clash_test.go +++ b/internal/web/service/setting_clash_test.go @@ -114,6 +114,9 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) { if settings.SubJsonUserAgentRegex != "" { t.Fatalf("subJsonUserAgentRegex = %q, want empty inherited value", settings.SubJsonUserAgentRegex) } + if settings.SubClashRemoteDNS { + t.Fatal("subClashRemoteDNS default = true, want false") + } if settings.SubClashUserAgentRegex != "" { t.Fatalf("subClashUserAgentRegex = %q, want empty inherited value", settings.SubClashUserAgentRegex) } @@ -130,6 +133,7 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) { settings.SubClashURI = "https://subscriptions.example.com/clash-custom/" settings.SubClashEnableRouting = true settings.SubClashRules = "GEOIP,private,DIRECT" + settings.SubClashRemoteDNS = true if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil { t.Fatal(err) @@ -178,6 +182,9 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) { if got.SubClashRules != "GEOIP,private,DIRECT" { t.Fatalf("subClashRules = %q, want %q", got.SubClashRules, "GEOIP,private,DIRECT") } + if !got.SubClashRemoteDNS { + t.Fatal("subClashRemoteDNS = false, want true") + } } func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) { diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index aaddeb8bd..66addd333 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.", "subClashRoutingRules": "قواعد التوجيه العامة", "subClashRoutingRulesDesc": "ألصق قواعد/YAML أو عنوان HTTPS دائمًا. تحدّثه اللوحة في الخلفية، وتستورد المجموعات وموفري القواعد والقواعد فقط، وتحافظ على عقد VPN المُنشأة وآخر قيمة صالحة.", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "IP الاستماع", "subListenDesc": "عنوان IP لخدمة الاشتراك. (سيبه فاضي عشان يستمع على كل الـ IPs)", "subPort": "بورت الاستماع", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 00f92c668..6f520bd98 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1428,6 +1428,8 @@ "subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.", "subClashRoutingRules": "Global routing rules", "subClashRoutingRulesDesc": "Paste inline rules/YAML or one permanent HTTPS URL. The panel refreshes it in the background, imports only groups/providers/rules, preserves generated VPN nodes and keeps the last valid value.", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "Listen IP", "subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)", "subPort": "Listen Port", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index ee8726311..9c3cd8815 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.", "subClashRoutingRules": "Reglas globales de enrutamiento", "subClashRoutingRulesDesc": "Pegue reglas/YAML o una URL HTTPS permanente. El panel la actualiza en segundo plano, importa solo grupos, proveedores de reglas y reglas, y conserva los nodos VPN generados y el último valor válido.", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "Listening IP", "subListenDesc": "Dejar en blanco por defecto para monitorear todas las IPs.", "subPort": "Puerto de Suscripción", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 167ed1110..2865ad109 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -1310,6 +1310,8 @@ "subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.", "subClashRoutingRules": "قوانین مسیریابی سراسری", "subClashRoutingRulesDesc": "قوانین/YAML یا یک نشانی دائمی HTTPS وارد کنید. پنل آن را در پس‌زمینه به‌روزرسانی می‌کند، فقط گروه‌ها، ارائه‌دهندگان قانون و قوانین را وارد می‌کند و گره‌های VPN ساخته‌شده و آخرین مقدار معتبر را حفظ می‌کند.", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "آدرس آی‌پی", "subListenDesc": "آدرس آی‌پی برای سرویس سابسکریپشن. برای گوش دادن به‌تمام آی‌پی‌ها خالی‌بگذارید", "subPort": "پورت", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 29c7ff3e7..ff143679b 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.", "subClashRoutingRules": "Aturan routing global", "subClashRoutingRulesDesc": "Tempel aturan/YAML atau satu URL HTTPS permanen. Panel memperbaruinya di latar belakang, hanya mengimpor grup, penyedia aturan, dan aturan, serta mempertahankan node VPN buatan panel dan nilai valid terakhir.", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "IP Pendengar", "subListenDesc": "Alamat IP untuk layanan langganan. (biarkan kosong untuk mendengarkan semua IP)", "subPort": "Port Pendengar", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 89ddbaa5c..9f78705c7 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。", "subClashRoutingRules": "グローバルルーティングルール", "subClashRoutingRulesDesc": "ルール/YAML、または永続的な HTTPS URL を入力します。パネルはバックグラウンドで更新し、グループ・ルールプロバイダー・ルールのみを取り込み、生成済み VPN ノードと最後の有効値を保持します。", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "監視IP", "subListenDesc": "サブスクリプションサービスが監視するIPアドレス(空白にするとすべてのIPを監視)", "subPort": "監視ポート", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index d93a88cc9..f2a2d4e9e 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.", "subClashRoutingRules": "Regras globais de roteamento", "subClashRoutingRulesDesc": "Cole regras/YAML ou uma URL HTTPS permanente. O painel a atualiza em segundo plano, importa apenas grupos, provedores de regras e regras, e preserva os nós VPN gerados e o último valor válido.", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "IP de Escuta", "subListenDesc": "O endereço IP para o serviço de assinatura. (deixe em branco para escutar em todos os IPs)", "subPort": "Porta de Escuta", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index bc18f2da1..8b60bc140 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.", "subClashRoutingRules": "Глобальные правила маршрутизации", "subClashRoutingRulesDesc": "Вставьте правила/YAML либо одну постоянную HTTPS-ссылку. Панель обновляет её в фоне, импортирует только группы, провайдеры правил и правила, сохраняет созданные VPN-узлы и последнее рабочее значение.", + "subClashRemoteDNS": "DNS из удалённых правил", + "subClashRemoteDNSDesc": "Если в глобальных правилах маршрутизации указана ссылка, брать из неё также разделы dns и sniffer. Включайте только для шаблона, которому доверяете.", "subListen": "Прослушивание IP", "subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса", "subPort": "Порт подписки", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 91b15722e..d46d1a32c 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.", "subClashRoutingRules": "Genel Yönlendirme Kuralları", "subClashRoutingRulesDesc": "Kurallar/YAML veya kalıcı bir HTTPS URL'si yapıştırın. Panel bunu arka planda yeniler, yalnızca grupları, kural sağlayıcılarını ve kuralları içe aktarır; oluşturulan VPN düğümlerini ve son geçerli değeri korur.", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "Dinleme IP", "subListenDesc": "Abonelik hizmeti için IP adresi. (tüm IP'leri dinlemek için boş bırakın)", "subPort": "Dinleme Portu", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index ce4adf620..61d6d8edd 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.", "subClashRoutingRules": "Глобальні правила маршрутизації", "subClashRoutingRulesDesc": "Вставте правила/YAML або одну постійну HTTPS-адресу. Панель оновлює її у фоні, імпортує лише групи, постачальників правил і правила, зберігаючи створені VPN-вузли та останнє коректне значення.", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "Слухати IP", "subListenDesc": "IP-адреса для служби підписки. (залиште порожнім, щоб слухати всі IP-адреси)", "subPort": "Слухати порт", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index c6c427e8f..c0c8d92ff 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.", "subClashRoutingRules": "Quy tắc định tuyến toàn cầu", "subClashRoutingRulesDesc": "Dán quy tắc/YAML hoặc một URL HTTPS cố định. Bảng điều khiển cập nhật trong nền, chỉ nhập nhóm, nhà cung cấp quy tắc và quy tắc, đồng thời giữ các nút VPN đã tạo và giá trị hợp lệ gần nhất.", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "Listening IP", "subListenDesc": "Mặc định để trống để nghe tất cả các IP", "subPort": "Cổng gói đăng ký", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 12134589d..22bf4cc7a 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。", "subClashRoutingRules": "全局路由规则", "subClashRoutingRulesDesc": "粘贴规则/YAML 或一个固定 HTTPS URL。面板会在后台更新,仅导入代理组、规则提供者和规则,并保留面板生成的 VPN 节点及最后一个有效值。", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "监听 IP", "subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)", "subPort": "监听端口", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 33838ccba..979acc5e8 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -1306,6 +1306,8 @@ "subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。", "subClashRoutingRules": "全域路由規則", "subClashRoutingRulesDesc": "貼上規則/YAML 或一個固定 HTTPS URL。面板會在背景更新,只匯入代理群組、規則提供者與規則,並保留面板產生的 VPN 節點及最後一個有效值。", + "subClashRemoteDNS": "Use DNS from remote rules", + "subClashRemoteDNSDesc": "When the global routing rules field is a URL, also take its dns and sniffer sections. Enable only for a template you trust.", "subListen": "監聽 IP", "subListenDesc": "訂閱服務監聽的 IP 地址(留空表示監聽所有 IP)", "subPort": "監聽埠",