mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 06:45:01 +03:00
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:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": "بورت الاستماع",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "پورت",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "監視ポート",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1306,6 +1306,8 @@
|
||||
"subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
|
||||
"subClashRoutingRules": "Глобальные правила маршрутизации",
|
||||
"subClashRoutingRulesDesc": "Вставьте правила/YAML либо одну постоянную HTTPS-ссылку. Панель обновляет её в фоне, импортирует только группы, провайдеры правил и правила, сохраняет созданные VPN-узлы и последнее рабочее значение.",
|
||||
"subClashRemoteDNS": "DNS из удалённых правил",
|
||||
"subClashRemoteDNSDesc": "Если в глобальных правилах маршрутизации указана ссылка, брать из неё также разделы dns и sniffer. Включайте только для шаблона, которому доверяете.",
|
||||
"subListen": "Прослушивание IP",
|
||||
"subListenDesc": "Оставьте пустым по умолчанию, чтобы отслеживать все IP-адреса",
|
||||
"subPort": "Порт подписки",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Слухати порт",
|
||||
|
||||
@@ -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ý",
|
||||
|
||||
@@ -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": "监听端口",
|
||||
|
||||
@@ -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": "監聽埠",
|
||||
|
||||
Reference in New Issue
Block a user