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:
DIMFLIX
2026-09-13 12:51:56 +03:00
committed by GitHub
parent aaa5e61cad
commit 2730e4d071
33 changed files with 709 additions and 5 deletions
+22
View File
@@ -28,6 +28,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/mhsanaei/3x-ui/v3/internal/xray/dnsconf"
)
//go:embed config.json
@@ -146,6 +147,7 @@ var defaultValueMap = map[string]string{
"subJsonMux": "",
"subJsonRules": "",
"subJsonRoutingRules": "",
"subJsonDns": "",
"subJsonFinalMask": "",
"subJsonObservatory": "",
"subThemeDir": "",
@@ -1023,6 +1025,10 @@ func (s *SettingService) GetSubJsonRoutingRules() (string, error) {
return s.getString("subJsonRoutingRules")
}
func (s *SettingService) GetSubJsonDns() (string, error) {
return s.getString("subJsonDns")
}
func (s *SettingService) GetSubJsonFinalMask() (string, error) {
return s.getString("subJsonFinalMask")
}
@@ -1339,6 +1345,9 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears
if err := validateSubUserAgentRegexes(allSetting); err != nil {
return err
}
if err := validateSubJsonDnsSetting(allSetting); err != nil {
return err
}
if err := allSetting.CheckValid(); err != nil {
return err
}
@@ -1508,6 +1517,19 @@ func validateRemoteRoutingURLSetting(name string, value *string) error {
return nil
}
// The same parser the sub server uses, so a value can never be saved as valid
// and then silently ignored at request time.
func validateSubJsonDnsSetting(allSetting *entity.AllSetting) error {
value := strings.TrimSpace(allSetting.SubJsonDns)
if value != "" {
if _, err := dnsconf.Parse(value); err != nil {
return common.NewError("JSON subscription DNS is invalid:", err.Error())
}
}
allSetting.SubJsonDns = value
return nil
}
func (s *SettingService) UpdateSecret(key string, value string) error {
switch key {
case "tgBotToken", "ldapPassword", "twoFactorToken":
@@ -0,0 +1,64 @@
package service
import (
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
)
func TestValidateSubJsonDnsSetting(t *testing.T) {
tests := []struct {
name string
value string
want string
wantError string
}{
{name: "blank is trimmed", value: " ", want: ""},
{name: "object passes through", value: ` {"servers": ["1.1.1.1"]} `, want: `{"servers": ["1.1.1.1"]}`},
{name: "array passes through", value: `["1.1.1.1", "tls://1.0.0.1"]`, want: `["1.1.1.1", "tls://1.0.0.1"]`},
{name: "object with hosts and strategy", value: `{"hosts":{"a":"b"},"queryStrategy":"UseIPv4","servers":["1.1.1.1"]}`, want: `{"hosts":{"a":"b"},"queryStrategy":"UseIPv4","servers":["1.1.1.1"]}`},
{name: "malformed JSON is rejected", value: `{"servers": [`, wantError: "JSON subscription DNS is invalid"},
{name: "broken field type is rejected", value: `{"servers": ["1.1.1.1"], "hosts": 5}`, wantError: "JSON subscription DNS is invalid"},
{name: "empty server list is rejected", value: `[]`, wantError: "JSON subscription DNS is invalid"},
{name: "server without address is rejected", value: `[{"skipFallback": true}]`, wantError: "JSON subscription DNS is invalid"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings := &entity.AllSetting{SubJsonDns: tt.value}
err := validateSubJsonDnsSetting(settings)
if tt.wantError != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantError) {
t.Fatalf("err=%v, want %q", err, tt.wantError)
}
return
}
if err != nil || settings.SubJsonDns != tt.want {
t.Fatalf("value=%q err=%v", settings.SubJsonDns, err)
}
})
}
}
func TestSubJsonDnsSettingDefaultsAndPersists(t *testing.T) {
setupSettingTestDB(t)
s := &SettingService{}
settings, err := s.GetAllSetting()
if err != nil {
t.Fatal(err)
}
if settings.SubJsonDns != "" {
t.Fatalf("expected empty default, got %q", settings.SubJsonDns)
}
settings.SubJsonDns = `["https://dns.google/dns-query"]`
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
t.Fatal(err)
}
got, err := s.GetSubJsonDns()
if err != nil || got != `["https://dns.google/dns-query"]` {
t.Fatalf("expected the stored DNS list, got %q, err %v", got, err)
}
}