mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-05 12:45:00 +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:
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user