Files
3x-ui-web-src/internal/web/service/xray_setting_dns_routing_test.go
Sanaei f727d04f65
CI / go-test (push) Canceled after 0s
CI / postgres-durable-first (push) Canceled after 0s
CI / codegen (push) Canceled after 0s
CI / govulncheck (push) Canceled after 0s
CI / race (push) Canceled after 0s
CI / fuzz-smoke (push) Canceled after 0s
CI / golangci (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CodeQL Advanced / Analyze (go) (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (javascript-typescript) (push) Canceled after 0s
Docs CI / build (push) Canceled after 0s
Docs Deploy (GitHub Pages) / build (push) Canceled after 0s
Release 3X-UI / build (386) (push) Canceled after 0s
Release 3X-UI / build (amd64) (push) Canceled after 0s
Release 3X-UI / build (arm64) (push) Canceled after 0s
Release 3X-UI / build (armv5) (push) Canceled after 0s
Release 3X-UI / build (armv6) (push) Canceled after 0s
Release 3X-UI / build (armv7) (push) Canceled after 0s
Release 3X-UI / build (s390x) (push) Canceled after 0s
Release 3X-UI / Build for Windows (amd64) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / noninteractive-install (ubuntu-latest) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-24.04-arm) (push) Canceled after 0s
Deploy Smoke Tests / release-tag-install (ubuntu-latest) (push) Canceled after 0s
Docs Deploy (GitHub Pages) / deploy (push) Canceled after 0s
Release 3X-UI / Publish rolling dev release (push) Canceled after 0s
v3.7.0
2026-08-24 15:07:15 +02:00

468 lines
16 KiB
Go

package service
import (
"encoding/json"
"testing"
)
func rulesFromRaw(t *testing.T, raw string) []map[string]any {
t.Helper()
var cfg map[string]any
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return routingRulesFromCfg(cfg)
}
func TestEnsureDnsServerRouting_NoOpWithoutDnsServers(t *testing.T) {
in := `{"routing":{"rules":[{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if out != in {
t.Fatalf("expected unchanged input, got: %s", out)
}
}
func TestEnsureDnsServerRouting_NoOpForPublicDnsServer(t *testing.T) {
in := `{
"dns": {"servers": ["1.1.1.1"]},
"routing": {"rules": [{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if out != in {
t.Fatalf("expected unchanged input for a public DNS server, got: %s", out)
}
}
func TestEnsureDnsServerRouting_NoOpWithoutPrivateBlockRule(t *testing.T) {
// Private DNS server, but nothing in routing would block it — no rule
// needed.
in := `{
"dns": {"servers": ["172.20.0.53"]},
"routing": {"rules": [{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if out != in {
t.Fatalf("expected unchanged input without a private-block rule, got: %s", out)
}
}
func TestEnsureDnsServerRouting_InsertsAllowRuleBeforeBlock(t *testing.T) {
// Reproduces the reported bug: dns.servers on a private docker IP
// (e.g. a same-network AdGuard Home) plus the panel's default
// geoip:private block rule silently drops Xray's own DNS traffic.
in := `{
"dns": {"servers": ["172.20.0.53"], "queryStrategy": "UseIPv4"},
"routing": {
"rules": [
{"type":"field","inboundTag":["api"],"outboundTag":"api"},
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]},
{"type":"field","outboundTag":"blocked","protocol":["bittorrent"]}
]
}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if len(rules) != 4 {
t.Fatalf("rules len = %d, want 4: %s", len(rules), out)
}
if tag, _ := rules[1]["outboundTag"].(string); tag != "direct" {
t.Fatalf("expected inserted allow-rule at index 1, got outboundTag %v\nfull: %s", rules[1]["outboundTag"], out)
}
ips := readRuleIPs(rules[1]["ip"])
if len(ips) != 1 || ips[0] != "172.20.0.53" {
t.Fatalf("allow-rule ip = %v, want [172.20.0.53]", ips)
}
if port, _ := rules[1]["port"].(string); port != "53" {
t.Fatalf("allow-rule port = %v, want \"53\" (scoped to DNS traffic only)", rules[1]["port"])
}
if tag, _ := rules[2]["outboundTag"].(string); tag != "blocked" {
t.Fatalf("private-block rule should still follow the allow-rule, got %v", rules[2])
}
}
func TestEnsureDnsServerRouting_HandlesObjectServerEntryWithExplicitPort(t *testing.T) {
in := `{
"dns": {"servers": [{"address": "172.20.0.53", "port": 5353}]},
"routing": {"rules": [{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
ips := readRuleIPs(rules[0]["ip"])
if len(ips) != 1 || ips[0] != "172.20.0.53" {
t.Fatalf("allow-rule ip = %v, want [172.20.0.53]", ips)
}
if port, _ := rules[0]["port"].(string); port != "5353" {
t.Fatalf("allow-rule port = %v, want the object's explicit \"5353\"", rules[0]["port"])
}
}
func TestEnsureDnsServerRouting_GroupsDistinctPortsIntoSeparateRules(t *testing.T) {
// Two internal resolvers on different ports must not be merged into
// one ip+port rule — that would cross-allow ip1:port2 and ip2:port1,
// widening the exception beyond what's actually needed.
in := `{
"dns": {"servers": ["172.20.0.53", "10.0.0.53:5353"]},
"routing": {"rules": [{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if len(rules) != 3 {
t.Fatalf("rules len = %d, want 3 (one rule per port + the block rule): %s", len(rules), out)
}
if p, _ := rules[0]["port"].(string); p != "53" {
t.Fatalf("rules[0] port = %v, want \"53\" (sorted ascending)", rules[0]["port"])
}
if ips := readRuleIPs(rules[0]["ip"]); len(ips) != 1 || ips[0] != "172.20.0.53" {
t.Fatalf("rules[0] ip = %v, want [172.20.0.53]", ips)
}
if p, _ := rules[1]["port"].(string); p != "5353" {
t.Fatalf("rules[1] port = %v, want \"5353\"", rules[1]["port"])
}
if ips := readRuleIPs(rules[1]["ip"]); len(ips) != 1 || ips[0] != "10.0.0.53" {
t.Fatalf("rules[1] ip = %v, want [10.0.0.53]", ips)
}
}
func TestEnsureDnsServerRouting_StripsSchemePortAndPath(t *testing.T) {
in := `{
"dns": {"servers": [
"tcp://10.0.0.53:5353",
"https+local://192.168.1.1/dns-query",
"[fd00::53]:53"
]},
"routing": {"rules": [{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
// 192.168.1.1 and fd00::53 share the default port 53; 10.0.0.53:5353
// is its own group.
if len(rules) != 3 {
t.Fatalf("rules len = %d, want 3 (2 port groups + block rule): %s", len(rules), out)
}
port53 := rules[0]
if p, _ := port53["port"].(string); p != "53" {
t.Fatalf("rules[0] port = %v, want \"53\"", port53["port"])
}
want53 := map[string]bool{"192.168.1.1": true, "fd00::53": true}
ips := readRuleIPs(port53["ip"])
if len(ips) != len(want53) {
t.Fatalf("rules[0] ip = %v, want entries matching %v", ips, want53)
}
for _, ip := range ips {
if !want53[ip] {
t.Fatalf("unexpected ip %q in port-53 rule %v", ip, ips)
}
}
port5353 := rules[1]
if p, _ := port5353["port"].(string); p != "5353" {
t.Fatalf("rules[1] port = %v, want \"5353\"", port5353["port"])
}
if ips := readRuleIPs(port5353["ip"]); len(ips) != 1 || ips[0] != "10.0.0.53" {
t.Fatalf("rules[1] ip = %v, want [10.0.0.53]", ips)
}
}
func TestEnsureDnsServerRouting_SkipsSpecialAndDomainAddresses(t *testing.T) {
in := `{
"dns": {"servers": ["localhost", "fakedns", "dns.google", "8.8.8.8"]},
"routing": {"rules": [{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if out != in {
t.Fatalf("expected unchanged input (no private literal IPs present), got: %s", out)
}
}
func TestEnsureDnsServerRouting_IdempotentOnSecondSave(t *testing.T) {
in := `{
"dns": {"servers": ["172.20.0.53"]},
"routing": {"rules": [{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}
}`
first, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
second, err := EnsureDnsServerRouting(first)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if second != first {
t.Fatalf("expected no further change on second pass\nfirst: %s\nsecond: %s", first, second)
}
}
func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) {
// A legacy (untagged) managed rule matching the current dns.servers is
// adopted: rebuilt once with the ruleTag marker, then stable.
in := `{
"dns": {"servers": ["172.20.0.53"]},
"routing": {
"rules": [
{"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
]
}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if len(rules) != 2 {
t.Fatalf("rules len = %d, want 2 (legacy rule adopted in place): %s", len(rules), out)
}
if tag, _ := rules[0]["ruleTag"].(string); tag != dnsAllowRuleTag {
t.Fatalf("adopted rule should carry %q, got %v", dnsAllowRuleTag, rules[0])
}
stable, err := EnsureDnsServerRouting(out)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if stable != out {
t.Fatalf("expected no further change once tagged\nfirst: %s\nsecond: %s", out, stable)
}
// Admin adds a second internal resolver on the same port.
in2 := `{
"dns": {"servers": ["172.20.0.53", "10.0.0.53"]},
"routing": {
"rules": [
{"type":"field","ruleTag":"xui-dns-allow","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
]
}
}`
out2, err := EnsureDnsServerRouting(in2)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules = rulesFromRaw(t, out2)
if len(rules) != 2 {
t.Fatalf("rules len = %d, want 2 (existing rule updated in place): %s", len(rules), out2)
}
ips := readRuleIPs(rules[0]["ip"])
if len(ips) != 2 || ips[0] != "10.0.0.53" || ips[1] != "172.20.0.53" {
t.Fatalf("allow-rule ip = %v, want [10.0.0.53 172.20.0.53]", ips)
}
}
func TestEnsureDnsServerRouting_RemovesOwnedRuleWhenNoLongerNeeded(t *testing.T) {
// Admin switches dns.servers to a public resolver — the tagged managed
// rule is now dead weight and should be dropped.
in := `{
"dns": {"servers": ["1.1.1.1"]},
"routing": {
"rules": [
{"type":"field","ruleTag":"xui-dns-allow","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
]
}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if len(rules) != 1 {
t.Fatalf("rules len = %d, want 1 (stale allow-rule removed): %s", len(rules), out)
}
if tag, _ := rules[0]["outboundTag"].(string); tag != "blocked" {
t.Fatalf("remaining rule should be the block rule, got %v", rules[0])
}
}
func TestEnsureDnsServerRouting_KeepsManualDirectRuleOnSave(t *testing.T) {
// The #6056 regression: a hand-written "LAN service over direct" rule
// (CIDR ip + custom port, no ruleTag) matches nothing the panel
// manages and must survive a save untouched, even when dns.servers has
// no private entries at all.
in := `{
"dns": {"servers": ["1.1.1.1"]},
"routing": {
"rules": [
{"type":"field","ip":["192.168.178.0/24"],"port":"5000","outboundTag":"direct","enabled":true},
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
]
}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if out != in {
t.Fatalf("manual direct rule must survive the save untouched, got: %s", out)
}
// Same shape with a single literal private IP — indistinguishable from
// a legacy managed rule only if it matches a configured dns server,
// which it doesn't here, so it must survive too.
in2 := `{
"dns": {"servers": ["1.1.1.1"]},
"routing": {
"rules": [
{"type":"field","ip":["192.168.178.4"],"port":"5000","outboundTag":"direct"},
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
]
}
}`
out2, err := EnsureDnsServerRouting(in2)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if out2 != in2 {
t.Fatalf("manual single-IP direct rule must survive the save untouched, got: %s", out2)
}
}
func TestEnsureDnsServerRouting_DoesNotTouchManualRuleWithExtraMatchers(t *testing.T) {
// A hand-written rule that also allows the DNS IP but carries an extra
// matcher isn't recognized as "ours" and must be left alone; a fresh
// managed rule is inserted alongside it instead.
in := `{
"dns": {"servers": ["172.20.0.53"]},
"routing": {
"rules": [
{"type":"field","ip":["172.20.0.53"],"port":"53","domain":["example.com"],"outboundTag":"direct"},
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
]
}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if len(rules) != 3 {
t.Fatalf("rules len = %d, want 3 (manual rule kept, managed rule inserted): %s", len(rules), out)
}
if _, ok := rules[0]["domain"]; !ok {
t.Fatalf("manual rule with domain matcher should be untouched, got %v", rules[0])
}
}
func TestEnsureDnsServerRouting_RepositionsRuleDraggedAfterBlockRule(t *testing.T) {
// The Routing tab lets admins freely drag rules around
// (RoutingTab.tsx move-up/move-down and drag-and-drop), and a managed
// rule renders as an indistinguishable normal row there. If it ends up
// at or after the block rule — directly, or incidentally while
// reordering something else — the exact bug this file fixes comes
// back silently: the block rule matches first and Xray's own DNS
// traffic is dropped again. Detecting only ip/content drift isn't
// enough; position must be checked too.
in := `{
"dns": {"servers": ["172.20.0.53"]},
"routing": {
"rules": [
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]},
{"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"}
]
}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if len(rules) != 2 {
t.Fatalf("rules len = %d, want 2: %s", len(rules), out)
}
if tag, _ := rules[0]["outboundTag"].(string); tag != "direct" {
t.Fatalf("managed rule should be re-homed to index 0 (before the block rule), got %v\nfull: %s", rules[0], out)
}
if tag, _ := rules[1]["outboundTag"].(string); tag != "blocked" {
t.Fatalf("block rule should now be at index 1, got %v\nfull: %s", rules[1], out)
}
}
func TestEnsureDnsServerRouting_TreatsExplicitlyEnabledRuleAsOwned(t *testing.T) {
// RuleFormModal.tsx's submit() always writes an "enabled" key, even
// when the admin changed nothing and it was already true — merely
// opening the auto-generated rule in the editor must not disown it.
in := `{
"dns": {"servers": ["172.20.0.53"]},
"routing": {
"rules": [
{"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct","enabled":true},
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
]
}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if len(rules) != 2 {
t.Fatalf("rules len = %d, want 2 (no duplicate inserted): %s", len(rules), out)
}
}
func TestEnsureDnsServerRouting_DisabledRuleIsDisownedAndReplaced(t *testing.T) {
// toggleRule() in RoutingTab.tsx writes enabled=false on a plain
// switch flip. An admin who explicitly disables the managed rule is
// choosing to turn the exception off; re-enabling it on the next save
// would silently override that. The disabled rule is left alone and a
// fresh, enabled one is (re-)created to keep the fix working.
in := `{
"dns": {"servers": ["172.20.0.53"]},
"routing": {
"rules": [
{"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct","enabled":false},
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
]
}
}`
out, err := EnsureDnsServerRouting(in)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
rules := rulesFromRaw(t, out)
if len(rules) != 3 {
t.Fatalf("rules len = %d, want 3 (disabled rule kept as-is, fresh managed rule added): %s", len(rules), out)
}
if enabled, _ := rules[0]["enabled"].(bool); enabled {
t.Fatalf("original disabled rule should be untouched, got %v", rules[0])
}
if _, ok := rules[1]["enabled"]; ok {
t.Fatalf("freshly (re-)generated rule shouldn't carry an enabled key, got %v", rules[1])
}
if tag, _ := rules[1]["outboundTag"].(string); tag != "direct" {
t.Fatalf("expected the fresh managed rule at index 1, got %v", rules[1])
}
}
func TestEnsureDnsServerRouting_InvalidJsonReturnsAsIs(t *testing.T) {
in := "definitely not json"
out, err := EnsureDnsServerRouting(in)
if err == nil {
t.Fatalf("expected error for invalid json, got none")
}
if out != in {
t.Fatalf("expected raw passthrough on error, got %q", out)
}
}