Files
3x-ui/internal/sub/json_dns_test.go
DIMFLIX 2730e4d071 feat(sub): let the panel set the JSON subscription DNS servers (#6485)
* feat(sub): let the panel set the JSON subscription DNS servers

A baked routing profile (#6402) carries only the DNS its preset defines, so an
operator who wants their own resolvers has to override the whole profile or
patch the subscription behind a proxy.

Add the subJsonDns setting: either a full xray dns block or a bare array of
servers. It wins over the profile's DNS while leaving the profile's routing
rules intact, and reaches per-inbound, balancer and info-node documents alike.

The value is validated with xray's own schema (internal/xray/dnsconf): a block
the client could not load is rejected when the settings are saved and ignored
with a warning at request time, instead of being baked into every document.
Both the sub server and the settings API share that validator, so a stored
value can never be silently dropped.

xray's Build() is deliberately not used for validation: it resolves geosite
tokens from the geodata files and would reject valid configs whenever those
are absent from the panel's working directory.

* style(dnsconf): drop the ineffectual initial map assignment

golangci's ineffassign flagged the zero-value map whose value both paths
overwrite: the object branch now assigns the decoded map directly.

* docs(sub): scope the DNS setting to the documents it rewrites

The Routing header mirrored to Happ/INCY keeps the routing profile's own
resolvers, so the setting description and the header-source comment now say
so instead of claiming the profile's DNS is replaced everywhere.

Also trims two comments in the new dnsconf package to the repo's two-line cap.
2026-09-13 11:51:56 +02:00

268 lines
8.9 KiB
Go

package sub
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
const dnsTestStream = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`
func docDnsBlock(t *testing.T, doc map[string]any) map[string]any {
t.Helper()
dns, _ := doc["dns"].(map[string]any)
if dns == nil {
t.Fatalf("doc has no dns block: %v", doc["dns"])
}
return dns
}
func onlySubJsonDoc(t *testing.T, js *SubJsonService, subId string) map[string]any {
t.Helper()
out, _, err := js.GetJson(subId, "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
if len(docs) != 1 {
t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
}
return docs[0]
}
// A bare servers array must replace the template resolver, not append to it.
func TestSubJsonDns_ArrayReplacesTemplateServers(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4901, 1, dnsTestStream)
js := NewSubJsonService("", "", "", "", NewSubService(""))
js.SetDnsConfig(`["https://dns.google/dns-query", {"address": "tls://1.1.1.1", "domains": ["geosite:youtube"]}]`)
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
servers, _ := dns["servers"].([]any)
if len(servers) != 2 {
t.Fatalf("servers = %v, want 2", servers)
}
if servers[0] != "https://dns.google/dns-query" {
t.Fatalf("servers[0] = %v", servers[0])
}
second, _ := servers[1].(map[string]any)
if second["address"] != "tls://1.1.1.1" {
t.Fatalf("servers[1] = %v", second)
}
if domains, _ := second["domains"].([]any); strings.Join(stringify(domains), ",") != "geosite:youtube" {
t.Fatalf("servers[1].domains = %v", second["domains"])
}
if _, hasTemplate := dns["tag"]; hasTemplate {
t.Fatalf("template dns keys leaked into the override: %v", dns)
}
}
func TestSubJsonDns_ObjectReplacesWholeBlock(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4902, 1, dnsTestStream)
js := NewSubJsonService("", "", "", "", NewSubService(""))
js.SetDnsConfig(`{"tag":"panel_dns","queryStrategy":"UseIPv4","disableCache":true,"hosts":{"example.com":"1.2.3.4"},"servers":[{"address":"1.1.1.1","skipFallback":true}]}`)
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
if dns["tag"] != "panel_dns" || dns["queryStrategy"] != "UseIPv4" || dns["disableCache"] != true {
t.Fatalf("dns header = %v", dns)
}
hosts, _ := dns["hosts"].(map[string]any)
if hosts["example.com"] != "1.2.3.4" {
t.Fatalf("dns hosts = %v", dns["hosts"])
}
servers, _ := dns["servers"].([]any)
if len(servers) != 1 {
t.Fatalf("servers = %v", servers)
}
server, _ := servers[0].(map[string]any)
if server["address"] != "1.1.1.1" || server["skipFallback"] != true {
t.Fatalf("server = %v", server)
}
}
// The explicit panel DNS block wins over the profile's, while the profile keeps
// owning the routing rules.
func TestSubJsonDns_OverridesRoutingProfileDns(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4903, 1, dnsTestStream)
js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
js.SetDnsConfig(`["9.9.9.9"]`)
doc := onlySubJsonDoc(t, js, "s1")
dns := docDnsBlock(t, doc)
servers, _ := dns["servers"].([]any)
if len(servers) != 1 || servers[0] != "9.9.9.9" {
t.Fatalf("servers = %v, want the panel override only", servers)
}
if hosts, _ := dns["hosts"].(map[string]any); len(hosts) != 0 {
t.Fatalf("profile dns hosts survived the override: %v", hosts)
}
want := "domain->block,domain->proxy,domain->direct,ip->direct,network->proxy"
if got := strings.Join(ruleSignatures(t, doc), ","); got != want {
t.Fatalf("rules = %v\nwant %v", got, want)
}
}
func TestSubJsonDns_InvalidSettingKeepsTemplateDns(t *testing.T) {
cases := []struct {
name string
value string
}{
{"malformed JSON", `{"servers": [`},
{"bare string", `"8.8.8.8"`},
{"servers not an array", `{"servers": "8.8.8.8"}`},
{"entry without address", `[{"domains": ["geosite:youtube"]}]`},
{"non-string entry", `[53]`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4904, 1, dnsTestStream)
js := NewSubJsonService("", "", "", "", NewSubService(""))
js.SetDnsConfig(tc.value)
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
if dns["tag"] != "dns_out" || dns["queryStrategy"] != "UseIP" {
t.Fatalf("template dns header = %v", dns)
}
servers, _ := dns["servers"].([]any)
if len(servers) != 1 {
t.Fatalf("template servers = %v", servers)
}
first, _ := servers[0].(map[string]any)
if first["address"] != "8.8.8.8" {
t.Fatalf("template server = %v", first)
}
})
}
}
func TestSubJsonDns_BlankKeepsTemplateDns(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4905, 1, dnsTestStream)
js := NewSubJsonService("", "", "", "", NewSubService(""))
js.SetDnsConfig(" ")
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
servers, _ := dns["servers"].([]any)
first, _ := servers[0].(map[string]any)
if first["address"] != "8.8.8.8" {
t.Fatalf("template server = %v", first)
}
}
// Balancer documents are built from the same template, so they carry the
// override too.
func TestSubJsonDns_AppliesToBalancerDocuments(t *testing.T) {
seedSubDB(t)
tcp := seedSubInbound(t, "s1", "tcpin", 4906, 1, dnsTestStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "auto", Strategy: "random", InboundIds: []int{tcp.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
js.SetDnsConfig(`["https://dns.google/dns-query"]`)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
balancerDoc := findDocByRemarks(parseSubJsonDocs(t, out), "auto")
if balancerDoc == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
servers, _ := docDnsBlock(t, balancerDoc)["servers"].([]any)
if len(servers) != 1 || servers[0] != "https://dns.google/dns-query" {
t.Fatalf("balancer dns servers = %v", servers)
}
}
// The validator rejects a block whose types xray cannot decode, even when the
// servers list itself looks fine.
func TestSubJsonDns_BrokenBlockKeepsTemplateDns(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4907, 1, dnsTestStream)
js := NewSubJsonService("", "", "", "", NewSubService(""))
js.SetDnsConfig(`{"servers": ["1.1.1.1"], "hosts": 5}`)
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
servers, _ := dns["servers"].([]any)
first, _ := servers[0].(map[string]any)
if len(servers) != 1 || first["address"] != "8.8.8.8" {
t.Fatalf("template dns = %v", dns)
}
}
// An unusable routing profile degrades to an empty spec; the DNS override must
// still reach the document.
func TestSubJsonDns_AppliesWhenRoutingProfileUnusable(t *testing.T) {
seedSubDB(t)
seedSubInbound(t, "s1", "tcpin", 4908, 1, dnsTestStream)
js := NewSubJsonService("", "", "", "not-a-routing-payload", NewSubService(""))
js.SetDnsConfig(`["9.9.9.9"]`)
doc := onlySubJsonDoc(t, js, "s1")
servers, _ := docDnsBlock(t, doc)["servers"].([]any)
if len(servers) != 1 || servers[0] != "9.9.9.9" {
t.Fatalf("dns servers = %v", servers)
}
want := "network->proxy"
if got := strings.Join(ruleSignatures(t, doc), ","); got != want {
t.Fatalf("rules = %v, want the plain template rule %v", got, want)
}
}
// The dummy info node is emitted as a document too, so it carries the panel DNS.
func TestSubJsonDns_AppliesToInfoNodeDocument(t *testing.T) {
setupInfoNodeTestDB(t)
db := database.GetDB()
ib := &model.Inbound{
Id: 1, UserId: 1, Remark: "Germany-VLESS", Enable: true, Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-json","enable":true,"totalGB":10737418240}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(ib).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientRecord{Id: 1, Email: "user1@test.com", SubID: "sub-json", UUID: "c1-uuid", Enable: true, TotalGB: 10737418240}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "user1@test.com", Up: 1073741824, Down: 1073741824, Total: 10737418240, Enable: true}).Error; err != nil {
t.Fatal(err)
}
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
sub.subInfoNodeEnable = true
js := NewSubJsonService("", "", "", "", sub)
js.SetDnsConfig(`["https://dns.google/dns-query"]`)
out, _, err := js.GetJson("sub-json", "sub.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
if len(docs) != 2 {
t.Fatalf("docs = %d, want info node + inbound:\n%s", len(docs), out)
}
servers, _ := docDnsBlock(t, docs[0])["servers"].([]any)
if len(servers) != 1 || servers[0] != "https://dns.google/dns-query" {
t.Fatalf("info node dns servers = %v", servers)
}
}