From 79d8a9161bbb1e39d38bf8b0446bd70fa30992c0 Mon Sep 17 00:00:00 2001 From: Alireza Ahmadi Date: Sat, 10 Oct 2026 22:57:03 +0200 Subject: [PATCH] Finalmask: xDNS domain names lists and resolver addrs (Xray-core v26.10.10) --- database/migrations/migrations.go | 2 + database/migrations/v010_xdns.go | 79 ++++++++++ database/migrations/v010_xdns_test.go | 68 ++++++++ web/assets/js/model/inbound.js | 24 ++- web/assets/js/model/outbound.js | 62 +++++--- web/html/xui/form/outbound.html | 30 ++-- .../form/stream/stream_finalmask_fields.html | 17 +- web/translation/translate.en_US.toml | 2 + web/translation/translate.fa_IR.toml | 2 + web/translation/translate.ru_RU.toml | 2 + web/translation/translate.vi_VN.toml | 2 + web/translation/translate.zh_Hans.toml | 2 + xray/finalmask.go | 146 ++++++++++++++++++ xray/finalmask_test.go | 49 ++++++ 14 files changed, 440 insertions(+), 47 deletions(-) create mode 100644 database/migrations/v010_xdns.go create mode 100644 database/migrations/v010_xdns_test.go diff --git a/database/migrations/migrations.go b/database/migrations/migrations.go index 5f78a84f..d6032590 100644 --- a/database/migrations/migrations.go +++ b/database/migrations/migrations.go @@ -13,6 +13,7 @@ const ( VersionWireguard = 7 VersionCore26930 = 8 VersionSSKeys = 9 + VersionXDNS = 10 ) type migration struct { @@ -29,6 +30,7 @@ var registry = []migration{ {VersionWireguard, migrateV007Wireguard}, {VersionCore26930, migrateV008OutboundCore}, {VersionSSKeys, migrateV009ShadowsocksKeys}, + {VersionXDNS, migrateV010XDNS}, } func Run(db *gorm.DB) error { diff --git a/database/migrations/v010_xdns.go b/database/migrations/v010_xdns.go new file mode 100644 index 00000000..bb1657aa --- /dev/null +++ b/database/migrations/v010_xdns.go @@ -0,0 +1,79 @@ +package migrations + +import ( + "encoding/json" + + "github.com/alireza0/x-ui/database/model" + "github.com/alireza0/x-ui/xray" + + "gorm.io/gorm" +) + +// migrateV010XDNS rewrites stored xDNS masks for Xray-core v26.10.10, which +// takes a list of "names" per domain and resolvers as {"addrs": [...]}. The +// core ignores the older keys without an error, so inbounds and outbounds +// saved by earlier panels would keep running with no xDNS domain or resolver. +func migrateV010XDNS(db *gorm.DB) error { + tx := db.Begin() + var err error + defer func() { + if err == nil { + tx.Commit() + } else { + tx.Rollback() + } + }() + + // upgrade returns the rewritten stream settings, or "" when there is + // nothing to change. + upgrade := func(raw string) (string, error) { + stream := map[string]any{} + if raw == "" || json.Unmarshal([]byte(raw), &stream) != nil || !xray.UpgradeXDNSMasks(stream) { + return "", nil + } + modified, err := json.MarshalIndent(stream, "", " ") + return string(modified), err + } + + var inbounds []*model.Inbound + err = tx.Model(model.Inbound{}).Find(&inbounds).Error + if err != nil { + return err + } + for _, inbound := range inbounds { + var stream string + stream, err = upgrade(inbound.StreamSettings) + if err != nil { + return err + } + if stream == "" { + continue + } + err = tx.Model(model.Inbound{}).Where("id = ?", inbound.Id).Update("stream_settings", stream).Error + if err != nil { + return err + } + } + + var outbounds []*model.Outbound + err = tx.Model(model.Outbound{}).Find(&outbounds).Error + if err != nil { + return err + } + for _, outbound := range outbounds { + var stream string + stream, err = upgrade(outbound.StreamSettings) + if err != nil { + return err + } + if stream == "" { + continue + } + err = tx.Model(model.Outbound{}).Where("id = ?", outbound.Id).Update("stream_settings", stream).Error + if err != nil { + return err + } + } + + return nil +} diff --git a/database/migrations/v010_xdns_test.go b/database/migrations/v010_xdns_test.go new file mode 100644 index 00000000..d66bb35b --- /dev/null +++ b/database/migrations/v010_xdns_test.go @@ -0,0 +1,68 @@ +package migrations + +import ( + "testing" + + "github.com/alireza0/x-ui/database/model" +) + +const v26930XDNSStream = `{"network":"kcp","finalmask":{"udp":[{"type":"xdns","settings":{ + "domains":[{"name":"t.example.com","types":[16]}], + "resolvers":[{"type":"udp","settings":{"addr":"1.1.1.1:53"}}]}}]}}` + +func TestV010UpgradesInboundsAndOutbounds(t *testing.T) { + db := newTestDB(t) + if err := db.AutoMigrate(&model.Outbound{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + if err := db.Create(&model.Inbound{ + Id: 1, UserId: 1, Tag: "inbound-kcp", Protocol: model.VLESS, Port: 4443, Enable: true, + Settings: `{}`, + StreamSettings: `{"network":"kcp","finalmask":{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com"}]}}]}}`, + }).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + if err := db.Create(&model.Outbound{Tag: "xdns", Protocol: "vless", Settings: `{}`, StreamSettings: v26930XDNSStream}).Error; err != nil { + t.Fatalf("seed outbound: %v", err) + } + + if err := migrateV010XDNS(db); err != nil { + t.Fatalf("migrate: %v", err) + } + + var inbound model.Inbound + if err := db.First(&inbound, 1).Error; err != nil { + t.Fatalf("reload inbound: %v", err) + } + assertSameJSON(t, inbound.StreamSettings, + `{"network":"kcp","finalmask":{"udp":[{"type":"xdns","settings":{"domains":[{"names":["t.example.com"]}]}}]}}`) + + var outbound model.Outbound + if err := db.First(&outbound).Error; err != nil { + t.Fatalf("reload outbound: %v", err) + } + assertSameJSON(t, outbound.StreamSettings, `{"network":"kcp","finalmask":{"udp":[{"type":"xdns","settings":{ + "domains":[{"names":["t.example.com"],"types":[16]}], + "resolvers":[{"addrs":["udp://1.1.1.1:53"]}]}}]}}`) +} + +func TestV010LeavesOtherStreamsAlone(t *testing.T) { + const stream = "{\n \"network\": \"tcp\"\n}" + db := newTestDB(t) + if err := db.AutoMigrate(&model.Outbound{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + if err := db.Create(&model.Outbound{Tag: "direct", Protocol: "freedom", Settings: `{}`, StreamSettings: stream}).Error; err != nil { + t.Fatalf("seed: %v", err) + } + if err := migrateV010XDNS(db); err != nil { + t.Fatalf("migrate: %v", err) + } + var outbound model.Outbound + if err := db.First(&outbound).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if outbound.StreamSettings != stream { + t.Fatalf("untouched outbound was rewritten: %q", outbound.StreamSettings) + } +} diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 2b997aab..0caa5c6e 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1415,12 +1415,23 @@ class UdpMask extends XrayCommonClass { } } - // Xray-core v26.9.30 turned xDNS domains into objects; older configs carry a - // bare "domain" or a list of names. + // Xray-core v26.10.10 gives each xDNS domain a list of "names". Older + // configs carry v26.9.30's {"name"} objects, a bare "domain" or a list of + // names. static xdnsDomains(settings = {}) { - return [].concat(settings.domain || [], settings.domains || []) - .map(d => typeof d === 'string' ? { name: d, types: [16] } : d) - .filter(d => d && d.name !== undefined); + return [].concat(settings.domain || [], settings.domains || []).map(d => { + if (typeof d === 'string') return { names: [d], types: [] }; + if (!d || typeof d !== 'object') return null; + const { name, ...rest } = d; + return { names: d.names || (name ? [name] : []), ...rest, types: d.types || [] }; + }).filter(Boolean); + } + + // Empty record types leave the choice to the core, which answers all four + // on the server. + static xdnsDomainsToJson(domains = []) { + return domains.map(d => Object.fromEntries(Object.entries(d).filter(([, v]) => + !(v === '' || v == null || (Array.isArray(v) && v.length === 0))))); } // Realm settings without the parts left at their defaults. The TLS config for @@ -1565,6 +1576,9 @@ class UdpMask extends XrayCommonClass { if (this.type === 'noise') { settings = { reset: settings.reset || undefined, noise: (settings.noise || []).map(n => UdpMask.noiseItemToJson(n)) }; } + if (this.type === 'xdns') { + settings = { domains: UdpMask.xdnsDomainsToJson(settings.domains) }; + } if (this.type === 'xicmp') { settings = { dgram: settings.dgram || undefined, ips: settings.ips?.length ? settings.ips : undefined }; } diff --git a/web/assets/js/model/outbound.js b/web/assets/js/model/outbound.js index f1b8b342..385eaf69 100644 --- a/web/assets/js/model/outbound.js +++ b/web/assets/js/model/outbound.js @@ -908,35 +908,52 @@ class UdpMask extends CommonClass { } } - // Xray-core v26.9.30 turned xDNS domains and resolvers into objects. Older - // configs carry a bare "domain", or plain string lists where a resolver reads - // "name[:type]+udp://addr". + // Xray-core v26.10.10 gives each xDNS domain a list of "names" and takes + // resolvers as {"addrs": ["udp://host:port", ...]}. Older configs carry + // v26.9.30's {"name"} and {"type", "settings": {"addr"}} objects, or plain + // strings where a resolver reads "name[:type]+udp://addr". The form keeps + // the resolvers as one flat list of addresses. static xdnsSettings(settings = {}) { - const domains = []; + const domains = UdpMask.xdnsDomains(settings); const resolvers = []; - const addDomain = (name, types = [16]) => { - const known = domains.find(d => d.name === name); - if (known) known.types = [...new Set([...(known.types || []), ...types])]; - else if (name) domains.push({ name, types }); + const addType = (name, type) => { + const known = domains.find(d => d.names.includes(name)); + if (!known) domains.push({ names: [name], types: [type] }); + else if (!known.types.includes(type)) known.types.push(type); }; - for (const d of [].concat(settings.domain || [], settings.domains || [])) { - if (typeof d === 'string') addDomain(d); - else if (d && typeof d === 'object') domains.push(d); - } for (const r of settings.resolvers || []) { if (typeof r === 'string') { - const [head, addr] = r.split('+udp://'); - if (!addr) continue; - const [name, type] = head.split(':'); - addDomain(name, [UdpMask.XdnsTypes[(type || 'txt').toUpperCase()] || 16]); - resolvers.push({ type: 'udp', settings: { addr } }); - } else if (r && typeof r === 'object') { - resolvers.push({ type: r.type || 'udp', settings: { addr: r.settings?.addr || '' } }); + const plus = r.indexOf('+'); + const addr = r.slice(plus + 1); + if (plus < 0 || !addr.includes('://')) continue; + const [name, type] = r.slice(0, plus).split(':'); + if (name) addType(name, UdpMask.XdnsTypes[(type || 'txt').toUpperCase()] || 16); + resolvers.push(addr); + } else if (Array.isArray(r?.addrs)) { + resolvers.push(...r.addrs); + } else if (r?.settings?.addr) { + resolvers.push(`${r.type || 'udp'}://${r.settings.addr}`); } } return { domains, resolvers, extraPoll: settings.extraPoll ?? 0 }; } + static xdnsDomains(settings = {}) { + return [].concat(settings.domain || [], settings.domains || []).map(d => { + if (typeof d === 'string') return { names: [d], types: [] }; + if (!d || typeof d !== 'object') return null; + const { name, ...rest } = d; + return { names: d.names || (name ? [name] : []), ...rest, types: d.types || [] }; + }).filter(Boolean); + } + + // Empty record types leave the choice to the core: TXT on the client, all + // four on the server. + static xdnsDomainsToJson(domains = []) { + return domains.map(d => Object.fromEntries(Object.entries(d).filter(([, v]) => + !(v === '' || v == null || (Array.isArray(v) && v.length === 0))))); + } + // Realm settings without the parts left at their defaults. The TLS config for // the rendezvous server is edited as text; text that does not parse is passed // on as is, so the core reports what is wrong with it. @@ -1080,6 +1097,13 @@ class UdpMask extends CommonClass { settings = { ...settings, noise: (settings.noise || []).map(n => UdpMask.noiseItemToJson(n)) }; if (!settings.reset) delete settings.reset; } + if (this.type === 'xdns') { + settings = { + ...settings, + domains: UdpMask.xdnsDomainsToJson(settings.domains), + resolvers: settings.resolvers?.length ? [{ addrs: settings.resolvers }] : [], + }; + } if (['xdns', 'xicmp', 'udphop'].includes(this.type)) { settings = Object.fromEntries(Object.entries(settings).filter(([, v]) => !(v === '' || v === 0 || v === false || (Array.isArray(v) && v.length === 0)))); diff --git a/web/html/xui/form/outbound.html b/web/html/xui/form/outbound.html index a78000c4..c9af399b 100644 --- a/web/html/xui/form/outbound.html +++ b/web/html/xui/form/outbound.html @@ -914,26 +914,18 @@