Files
3x-ui/internal/sub/clash_remote_dns_test.go
T
Duxxie c3394fefed 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.
2026-10-24 04:21:04 +02:00

163 lines
5.2 KiB
Go

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())
}
})
}
}