mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
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.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package dnsconf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr string
|
||||
wantNil bool
|
||||
wantCount int
|
||||
}{
|
||||
{name: "blank", value: " ", wantNil: true},
|
||||
{name: "array of strings", value: `["1.1.1.1", "tls://1.0.0.1"]`, wantCount: 2},
|
||||
{name: "array with object entry", value: `[{"address": "1.1.1.1", "domains": ["geosite:youtube"]}]`, wantCount: 1},
|
||||
{name: "object with hosts and strategy", value: `{"queryStrategy": "UseIPv4", "hosts": {"example.com": "1.2.3.4"}, "servers": ["https://dns.google/dns-query"]}`, wantCount: 1},
|
||||
{name: "hosts value list", value: `{"hosts": {"example.com": ["1.2.3.4", "5.6.7.8"]}, "servers": ["1.1.1.1"]}`, wantCount: 1},
|
||||
{name: "comma separated domains", value: `[{"address": "1.1.1.1", "domains": "geosite:youtube,geosite:netflix"}]`, wantCount: 1},
|
||||
{name: "client ip set", value: `{"clientIp": "1.2.3.4", "servers": [{"address": "1.1.1.1", "clientIp": "2001:db8::1"}]}`, wantCount: 1},
|
||||
|
||||
{name: "malformed JSON", value: `[`, wantErr: "invalid DNS JSON"},
|
||||
{name: "scalar", value: `42`, wantErr: "must be a JSON object or an array of servers"},
|
||||
{name: "empty array", value: `[]`, wantErr: `"servers" must list at least one DNS server`},
|
||||
{name: "object without servers", value: `{"hosts": {"a": "b"}}`, wantErr: `"servers" must list at least one DNS server`},
|
||||
{name: "misspelled servers key", value: `{"server": ["1.1.1.1"]}`, wantErr: `"servers" must list at least one DNS server`},
|
||||
{name: "non-string server entry", value: `[53]`, wantErr: "invalid DNS config"},
|
||||
{name: "server without address", value: `[{"skipFallback": true}]`, wantErr: `needs a non-empty "address"`},
|
||||
{name: "empty server string", value: `[""]`, wantErr: "is empty"},
|
||||
{name: "empty server address", value: `[{"address": " "}]`, wantErr: `needs a non-empty "address"`},
|
||||
{name: "server address wrong type", value: `[{"address": 53}]`, wantErr: "invalid DNS config"},
|
||||
{name: "hosts wrong type", value: `{"hosts": 5, "servers": ["1.1.1.1"]}`, wantErr: "invalid DNS config"},
|
||||
{name: "strategy wrong type", value: `{"queryStrategy": 123, "servers": ["1.1.1.1"]}`, wantErr: "invalid DNS config"},
|
||||
{name: "client ip not an address", value: `{"clientIp": "not-an-ip", "servers": ["1.1.1.1"]}`, wantErr: "clientIp must be an IP address"},
|
||||
{name: "server client ip not an address", value: `[{"address": "1.1.1.1", "clientIp": "example.com"}]`, wantErr: "clientIp must be an IP address"},
|
||||
{name: "server port as string", value: `[{"address": "1.1.1.1", "port": "53"}]`, wantErr: "invalid DNS config"},
|
||||
{name: "server domains wrong type", value: `[{"address": "1.1.1.1", "domains": 5}]`, wantErr: "invalid DNS config"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
block, err := Parse(tc.value)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tc.wantNil {
|
||||
if block != nil {
|
||||
t.Fatalf("block = %v, want nil", block)
|
||||
}
|
||||
return
|
||||
}
|
||||
if block == nil {
|
||||
t.Fatal("block = nil")
|
||||
}
|
||||
servers, _ := block["servers"].([]any)
|
||||
if len(servers) != tc.wantCount {
|
||||
t.Fatalf("servers = %v, want %d", servers, tc.wantCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user