Files
3x-ui/internal/xray/dnsconf/dnsconf.go
T
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

120 lines
3.4 KiB
Go

// Package dnsconf validates the JSON-subscription DNS setting against xray's own
// schema, so a block the client could not load never reaches an emitted document.
package dnsconf
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/xtls/xray-core/infra/conf"
)
// Parse resolves the setting into the dns subtree to emit: a full dns object, or
// a bare array of servers wrapped into one. A blank value means "no override".
func Parse(raw string) (map[string]any, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil, nil
}
var decoded any
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
return nil, fmt.Errorf("invalid DNS JSON: %w", err)
}
block := make(map[string]any)
servers, isList := decoded.([]any)
if isList {
block["servers"] = servers
} else {
object, isObject := decoded.(map[string]any)
if !isObject {
return nil, errors.New("DNS config must be a JSON object or an array of servers")
}
block = object
}
if err := validate(block); err != nil {
return nil, err
}
return block, nil
}
// validate decodes the block into xray's own schema. Build() is deliberately not
// run: it resolves geosite tokens from geodata files the panel may not have.
func validate(block map[string]any) (err error) {
// Third-party parser fed by a panel setting: a panic must degrade to
// "unusable value", never take the panel or sub server down.
defer func() {
if panicValue := recover(); panicValue != nil {
err = fmt.Errorf("invalid DNS config: %v", panicValue)
}
}()
payload, err := json.Marshal(block)
if err != nil {
return fmt.Errorf("invalid DNS config: %w", err)
}
var parsed conf.DNSConfig
if err := json.Unmarshal(payload, &parsed); err != nil {
return fmt.Errorf("invalid DNS config: %w", err)
}
servers, _ := block["servers"].([]any)
if len(servers) == 0 {
// xray quietly installs the system resolver when no server is
// configured, which would leak lookups outside the tunnel.
return errors.New(`"servers" must list at least one DNS server`)
}
for index, entry := range servers {
if err := validateServer(index, entry); err != nil {
return err
}
}
if err := validateClientIP("dns", parsed.ClientIP); err != nil {
return err
}
for index, server := range parsed.Servers {
if err := validateClientIP(fmt.Sprintf("DNS server #%d", index+1), server.ClientIP); err != nil {
return err
}
}
return nil
}
// validateServer enforces the address rule Build() would: xray refuses a name
// server without one, but only reports it when the client starts.
func validateServer(index int, entry any) error {
label := fmt.Sprintf("DNS server #%d", index+1)
switch server := entry.(type) {
case string:
if strings.TrimSpace(server) == "" {
return fmt.Errorf("%s is empty", label)
}
case map[string]any:
address, ok := server["address"]
if !ok {
return fmt.Errorf(`%s needs a non-empty "address"`, label)
}
text, ok := address.(string)
if ok && strings.TrimSpace(text) == "" {
return fmt.Errorf(`%s needs a non-empty "address"`, label)
}
if _, isObject := address.(map[string]any); !ok && !isObject {
return fmt.Errorf(`%s needs a non-empty "address"`, label)
}
default:
return fmt.Errorf("%s must be a string or an object", label)
}
return nil
}
func validateClientIP(label string, clientIP *conf.Address) error {
if clientIP != nil && !clientIP.Family().IsIP() {
return fmt.Errorf("%s clientIp must be an IP address", label)
}
return nil
}