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
+8
View File
@@ -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",
+8
View File
@@ -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",
+2
View File
@@ -65,6 +65,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRemoteDNS": false,
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
@@ -252,6 +253,7 @@ export const EXAMPLES: Record<string, unknown> = {
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRemoteDNS": false,
"subClashRules": "",
"subClashURI": "",
"subClashUserAgentRegex": "",
+8
View File
@@ -216,6 +216,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashPath": {
"type": "string"
},
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": {
"type": "string"
},
@@ -636,6 +639,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRemoteDNS",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
@@ -992,6 +996,9 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashPath": {
"type": "string"
},
"subClashRemoteDNS": {
"type": "boolean"
},
"subClashRules": {
"type": "string"
},
@@ -1420,6 +1427,7 @@ export const SCHEMAS: Record<string, unknown> = {
"subClashEnable",
"subClashEnableRouting",
"subClashPath",
"subClashRemoteDNS",
"subClashRules",
"subClashURI",
"subClashUserAgentRegex",
+2
View File
@@ -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;
+2
View File
@@ -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(),
+1
View File
@@ -74,6 +74,7 @@ export class AllSetting {
subClashURI = '';
subClashEnableRouting = false;
subClashRules = '';
subClashRemoteDNS = false;
subJsonMux = '';
subJsonRules = '';
subJsonRoutingRules = '';
@@ -459,6 +459,16 @@ export default function SubscriptionGeneralTab({
onChange={(e) => updateSetting({ subClashRules: e.target.value })}
/>
</SettingListItem>
<SettingListItem
paddings="small"
title={t('pages.settings.subClashRemoteDNS')}
description={t('pages.settings.subClashRemoteDNSDesc')}
>
<Switch
checked={allSetting.subClashRemoteDNS}
onChange={(v) => updateSetting({ subClashRemoteDNS: v })}
/>
</SettingListItem>
</>
),
},
+1
View File
@@ -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(),
+6
View File
@@ -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';
@@ -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(
<MemoryRouter initialEntries={['/settings#subscription']}>
<SubscriptionGeneralTab
allSetting={new AllSetting({ subClashEnableRouting: true, subClashRemoteDNS: enabled })}
updateSetting={updateSetting}
/>
</MemoryRouter>,
);
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();
@@ -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 {
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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 {
+162
View File
@@ -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())
}
})
}
}
+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": {},
+1 -1
View File
@@ -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) {
+6 -1
View File
@@ -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{},
}
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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)
}
+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+6 -6
View File
@@ -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 {
@@ -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)
}
+6
View File
@@ -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),
+2 -2
View File
@@ -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)
}
+1 -1
View File
@@ -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)
+1
View File
@@ -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"`
+5
View File
@@ -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")
}
@@ -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) {
+2
View File
@@ -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": "بورت الاستماع",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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": "پورت",
+2
View File
@@ -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",
+2
View File
@@ -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": "監視ポート",
+2
View File
@@ -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",
+2
View File
@@ -1306,6 +1306,8 @@
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
"subClashRoutingRules": "Глобальные правила маршрутизации",
"subClashRoutingRulesDesc": "Вставьте правила/YAML либо одну постоянную HTTPS-ссылку. Панель обновляет её в фоне, импортирует только группы, провайдеры правил и правила, сохраняет созданные VPN-узлы и последнее рабочее значение.",
"subClashRemoteDNS": "DNS из удалённых правил",
"subClashRemoteDNSDesc": "Если в глобальных правилах маршрутизации указана ссылка, брать из неё также разделы dns и sniffer. Включайте только для шаблона, которому доверяете.",
"subListen": "Прослушивание IP",
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
"subPort": "Порт подписки",
+2
View File
@@ -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",
+2
View File
@@ -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": "Слухати порт",
+2
View File
@@ -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ý",
+2
View File
@@ -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": "监听端口",
+2
View File
@@ -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": "監聽埠",