Finalmask: xDNS domain names lists and resolver addrs (Xray-core v26.10.10)

This commit is contained in:
Alireza Ahmadi 2026-10-10 22:57:03 +02:00
parent 0b36fd86ba
commit 79d8a9161b
14 changed files with 440 additions and 47 deletions

View file

@ -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 {

View file

@ -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
}

View file

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

View file

@ -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 };
}

View file

@ -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))));

View file

@ -914,26 +914,18 @@
</template>
<template v-if="mask.type === 'xdns'">
{{template "form/xdnsDomains" "mask"}}
<a-form-item label='{{ i18n "pages.inbounds.stream.finalmask.resolvers" }}'>
<a-icon type="plus" type="primary" size="small"
@click="mask.settings.resolvers.push({type: 'udp', settings: {addr: ''}})" />
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.stream.finalmask.resolversDesc" }}</span>
</template>
{{ i18n "pages.inbounds.stream.finalmask.resolvers" }} <a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-select mode="tags" v-model="mask.settings.resolvers" :token-separators="[',', ' ']"
:dropdown-class-name="themeSwitcher.currentTheme" placeholder="udp://1.1.1.1:53"></a-select>
</a-form-item>
<template v-for="(r, rIdx) in mask.settings.resolvers" :key="'resolver-' + rIdx">
<a-divider :style="{ margin: '0' }"> {{ i18n "pages.inbounds.stream.finalmask.resolvers" }} [[ rIdx + 1 ]]
<a-icon type="delete"
@click="() => mask.settings.resolvers.splice(rIdx, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.inbounds.stream.hysteria.type" }}'>
<a-select v-model="r.type" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="udp">UDP</a-select-option>
<a-select-option value="tcp">TCP</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "address" }}'>
<a-input v-model.trim="r.settings.addr" placeholder="1.1.1.1:53"></a-input>
</a-form-item>
</template>
<a-form-item label='{{ i18n "pages.inbounds.stream.finalmask.extraPoll" }}'>
<a-input-number v-model.number="mask.settings.extraPoll" :min="0" :max="3"></a-input-number>
</a-form-item>

View file

@ -1,7 +1,7 @@
{{define "form/xdnsDomains"}}
<a-form-item label='{{ i18n "pages.inbounds.stream.finalmask.domains" }}'>
<a-icon type="plus" type="primary" size="small"
@click="{{.}}.settings.domains.push({name: '', types: [16], edns0: 1232})" />
@click="{{.}}.settings.domains.push({names: [], types: [], edns0: 1232})" />
</a-form-item>
<template v-for="(d, dIdx) in {{.}}.settings.domains" :key="'xdns-domain-' + dIdx">
<a-divider :style="{ margin: '0' }"> {{ i18n "pages.inbounds.stream.finalmask.domains" }} [[ dIdx + 1 ]]
@ -9,10 +9,19 @@
@click="() => {{.}}.settings.domains.splice(dIdx, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.domain" }}'>
<a-input v-model.trim="d.name" placeholder="t.example.com"></a-input>
<a-form-item label='{{ i18n "pages.inbounds.stream.finalmask.domains" }}'>
<a-select mode="tags" v-model="d.names" :token-separators="[',', ' ']"
:dropdown-class-name="themeSwitcher.currentTheme" placeholder="t.example.com"></a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.stream.finalmask.recordTypes" }}'>
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.stream.finalmask.recordTypesDesc" }}</span>
</template>
{{ i18n "pages.inbounds.stream.finalmask.recordTypes" }} <a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-select mode="multiple" v-model="d.types" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="(code, name) in UdpMask.XdnsTypes" :value="code">[[ name ]]</a-select-option>
</a-select>

View file

@ -447,7 +447,9 @@
"maxIncomingStreams" = "Max Incoming Streams"
"domains" = "Domains"
"resolvers" = "Resolvers"
"resolversDesc" = "udp://host:port or tcp://host:port. Without a scheme UDP is used, without a port 53."
"recordTypes" = "Record Types"
"recordTypesDesc" = "Leave empty for the default: TXT on the client, A, CNAME, TXT and AAAA on the server."
"edns0" = "EDNS0"
"extraPoll" = "Extra Poll"
"dgram" = "Unprivileged ICMP (DGRAM)"

View file

@ -447,7 +447,9 @@
"maxIncomingStreams" = "حداکثر استریم‌های ورودی"
"domains" = "دامنه‌ها"
"resolvers" = "ریزالورها"
"resolversDesc" = "به شکل udp://host:port یا tcp://host:port. بدون پروتکل UDP و بدون پورت 53 در نظر گرفته می‌شود."
"recordTypes" = "نوع رکوردها"
"recordTypesDesc" = "برای مقدار پیش‌فرض خالی بگذارید: TXT در کلاینت، و A، CNAME، TXT و AAAA در سرور."
"edns0" = "EDNS0"
"extraPoll" = "Poll اضافی"
"dgram" = "ICMP بدون دسترسی روت (DGRAM)"

View file

@ -447,7 +447,9 @@
"maxIncomingStreams" = "Макс. входящих потоков"
"domains" = "Домены"
"resolvers" = "Резолверы"
"resolversDesc" = "udp://host:port или tcp://host:port. Без схемы используется UDP, без порта — 53."
"recordTypes" = "Типы записей"
"recordTypesDesc" = "Оставьте пустым для значения по умолчанию: TXT на клиенте, A, CNAME, TXT и AAAA на сервере."
"edns0" = "EDNS0"
"extraPoll" = "Доп. опрос"
"dgram" = "Непривилегированный ICMP (DGRAM)"

View file

@ -447,7 +447,9 @@
"maxIncomingStreams" = "Max Incoming Streams"
"domains" = "Tên miền"
"resolvers" = "Trình phân giải"
"resolversDesc" = "udp://host:port hoặc tcp://host:port. Không có giao thức thì dùng UDP, không có cổng thì dùng 53."
"recordTypes" = "Loại bản ghi"
"recordTypesDesc" = "Để trống để dùng mặc định: TXT ở client, A, CNAME, TXT và AAAA ở server."
"edns0" = "EDNS0"
"extraPoll" = "Thăm dò bổ sung"
"dgram" = "ICMP không đặc quyền (DGRAM)"

View file

@ -447,7 +447,9 @@
"maxIncomingStreams" = "最大入站流"
"domains" = "域名"
"resolvers" = "解析器"
"resolversDesc" = "udp://host:port 或 tcp://host:port。未写协议时使用 UDP,未写端口时使用 53。"
"recordTypes" = "记录类型"
"recordTypesDesc" = "留空使用默认值:客户端为 TXT,服务端为 A、CNAME、TXT 和 AAAA。"
"edns0" = "EDNS0"
"extraPoll" = "额外轮询"
"dgram" = "非特权 ICMP (DGRAM)"

View file

@ -1,5 +1,7 @@
package xray
import "strings"
// Xray-core v26.9.30 dropped "finalmask.quicParams.udpHop" in favour of a
// client-only "udphop" UDP mask. The panel still keeps the port range in the
// inbound's quicParams, because share links and subscriptions are generated
@ -57,3 +59,147 @@ func MoveUDPHopToMask(stream map[string]any) bool {
finalmask["udp"] = append([]any{map[string]any{"type": "udphop", "settings": settings}}, udp...)
return true
}
// Xray-core v26.10.10 changed the xDNS mask again: a domain lists its "names"
// instead of a single "name", and resolvers are {"addrs": [...]} written as
// "udp://host:port" or "tcp://host:port". The panel stored v26.9.30's objects
// ({"name"}, {"type", "settings": {"addr"}}) and, before that, plain strings,
// where a resolver read "name[:type]+udp://addr". The core ignores all of
// these silently, leaving xDNS without domains or resolvers.
var xdnsRecordTypes = map[string]float64{"a": 1, "cname": 5, "txt": 16, "aaaa": 28}
// UpgradeXDNSMasks rewrites every xDNS mask in a stream settings map to the
// v26.10.10 schema and reports whether anything changed.
func UpgradeXDNSMasks(stream map[string]any) bool {
finalmask, _ := stream["finalmask"].(map[string]any)
udp, _ := finalmask["udp"].([]any)
changed := false
for _, mask := range udp {
m, _ := mask.(map[string]any)
if m["type"] != "xdns" {
continue
}
if settings, _ := m["settings"].(map[string]any); settings != nil && upgradeXDNSSettings(settings) {
changed = true
}
}
return changed
}
func upgradeXDNSSettings(settings map[string]any) bool {
changed := false
var domains []any
var addrs []any
// addType records a resolver's record type on the domain holding name,
// adding that domain when no domain names it yet.
addType := func(name string, recordType float64) {
for _, d := range domains {
domain, _ := d.(map[string]any)
names, _ := domain["names"].([]any)
for _, n := range names {
if n != name {
continue
}
types, _ := domain["types"].([]any)
for _, t := range types {
if t == recordType {
return
}
}
domain["types"] = append(types, recordType)
return
}
}
domains = append(domains, map[string]any{"names": []any{name}, "types": []any{recordType}})
}
var listed []any
if d, ok := settings["domain"]; ok {
delete(settings, "domain")
changed = true
if s, ok := d.(string); ok {
listed = append(listed, s)
} else if list, ok := d.([]any); ok {
listed = append(listed, list...)
}
}
if list, ok := settings["domains"].([]any); ok {
listed = append(listed, list...)
}
for _, d := range listed {
switch domain := d.(type) {
case string:
if domain != "" {
domains = append(domains, map[string]any{"names": []any{domain}})
}
changed = true
case map[string]any:
if _, ok := domain["names"]; !ok {
if name, _ := domain["name"].(string); name != "" {
domain["names"] = []any{name}
}
delete(domain, "name")
changed = true
}
domains = append(domains, domain)
}
}
var resolvers []any
stored, _ := settings["resolvers"].([]any)
for _, r := range stored {
switch resolver := r.(type) {
case string:
changed = true
head, addr, ok := strings.Cut(resolver, "+")
if !ok || !strings.Contains(addr, "://") {
continue
}
name, recordType, _ := strings.Cut(head, ":")
code, ok := xdnsRecordTypes[strings.ToLower(recordType)]
if !ok {
code = 16
}
if name != "" {
addType(name, code)
}
addrs = append(addrs, addr)
case map[string]any:
if _, ok := resolver["addrs"]; ok {
resolvers = append(resolvers, resolver)
continue
}
changed = true
inner, _ := resolver["settings"].(map[string]any)
addr, _ := inner["addr"].(string)
if addr == "" {
continue
}
network, _ := resolver["type"].(string)
if network == "" {
network = "udp"
}
addrs = append(addrs, network+"://"+addr)
}
}
if !changed {
return false
}
if len(addrs) > 0 {
resolvers = append(resolvers, map[string]any{"addrs": addrs})
}
if len(domains) > 0 {
settings["domains"] = domains
} else {
delete(settings, "domains")
}
if len(resolvers) > 0 {
settings["resolvers"] = resolvers
} else {
delete(settings, "resolvers")
}
return true
}

View file

@ -78,3 +78,52 @@ func TestMoveUDPHopToMaskWithoutPorts(t *testing.T) {
t.Fatalf("expected nothing left, got %v", stream)
}
}
func TestUpgradeXDNSMasksFromV26930(t *testing.T) {
stream := streamFrom(t, `{"finalmask":{"udp":[
{"type":"salamander","settings":{"password":"x"}},
{"type":"xdns","settings":{
"domains":[{"name":"t.example.com","types":[16,1],"edns0":1232}],
"resolvers":[{"type":"udp","settings":{"addr":"1.1.1.1:53"}},{"type":"tcp","settings":{"addr":"8.8.8.8:53"}}],
"extraPoll":2}}]}}`)
if !UpgradeXDNSMasks(stream) {
t.Fatal("reported no change")
}
want := streamFrom(t, `{"finalmask":{"udp":[
{"type":"salamander","settings":{"password":"x"}},
{"type":"xdns","settings":{
"domains":[{"names":["t.example.com"],"types":[16,1],"edns0":1232}],
"resolvers":[{"addrs":["udp://1.1.1.1:53","tcp://8.8.8.8:53"]}],
"extraPoll":2}}]}}`)
if !reflect.DeepEqual(stream, want) {
t.Fatalf("got %v, want %v", stream, want)
}
}
func TestUpgradeXDNSMasksFromStrings(t *testing.T) {
stream := streamFrom(t, `{"finalmask":{"udp":[{"type":"xdns","settings":{
"domain":"a.example.com",
"resolvers":["a.example.com:a+udp://1.1.1.1:53","b.example.com+udp://9.9.9.9:53","broken"]}}]}}`)
if !UpgradeXDNSMasks(stream) {
t.Fatal("reported no change")
}
want := streamFrom(t, `{"finalmask":{"udp":[{"type":"xdns","settings":{
"domains":[{"names":["a.example.com"],"types":[1]},{"names":["b.example.com"],"types":[16]}],
"resolvers":[{"addrs":["udp://1.1.1.1:53","udp://9.9.9.9:53"]}]}}]}}`)
if !reflect.DeepEqual(stream, want) {
t.Fatalf("got %v, want %v", stream, want)
}
}
func TestUpgradeXDNSMasksKeepsNewSchema(t *testing.T) {
const raw = `{"finalmask":{"udp":[{"type":"xdns","settings":{
"domains":[{"names":["a.example.com","b.example.com"]}],
"resolvers":[{"addrs":["1.1.1.1","tcp://8.8.8.8"]}]}}]}}`
stream := streamFrom(t, raw)
if UpgradeXDNSMasks(stream) {
t.Fatal("rewrote a mask already in the new schema")
}
if !reflect.DeepEqual(stream, streamFrom(t, raw)) {
t.Fatalf("changed to %v", stream)
}
}