mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-13 18:02:14 +03: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:
@@ -308,6 +308,9 @@
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonDns": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -545,6 +548,7 @@
|
||||
"subInfoNodeEnable",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonDns",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
@@ -896,6 +900,9 @@
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonDns": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1140,6 +1147,7 @@
|
||||
"subInfoNodeEnable",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonDns",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
|
||||
@@ -308,6 +308,9 @@
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonDns": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -545,6 +548,7 @@
|
||||
"subInfoNodeEnable",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonDns",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
@@ -896,6 +900,9 @@
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonDns": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1140,6 +1147,7 @@
|
||||
"subInfoNodeEnable",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonDns",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
|
||||
@@ -88,6 +88,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subInfoNodeEnable": false,
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonDns": "",
|
||||
"subJsonEnable": false,
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonMux": "",
|
||||
@@ -229,6 +230,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subInfoNodeEnable": false,
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonDns": "",
|
||||
"subJsonEnable": false,
|
||||
"subJsonFinalMask": "",
|
||||
"subJsonMux": "",
|
||||
|
||||
@@ -282,6 +282,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonDns": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -519,6 +522,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subInfoNodeEnable",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonDns",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
@@ -870,6 +874,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonAutoDetect": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subJsonDns": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1114,6 +1121,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subInfoNodeEnable",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonDns",
|
||||
"subJsonEnable",
|
||||
"subJsonFinalMask",
|
||||
"subJsonMux",
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface AllSetting {
|
||||
subInfoNodeEnable: boolean;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonDns: string;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
@@ -238,6 +239,7 @@ export interface AllSettingView {
|
||||
subInfoNodeEnable: boolean;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonDns: string;
|
||||
subJsonEnable: boolean;
|
||||
subJsonFinalMask: string;
|
||||
subJsonMux: string;
|
||||
|
||||
@@ -112,6 +112,7 @@ export const AllSettingSchema = z.object({
|
||||
subInfoNodeEnable: z.boolean(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonDns: z.string(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
@@ -255,6 +256,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subInfoNodeEnable: z.boolean(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonDns: z.string(),
|
||||
subJsonEnable: z.boolean(),
|
||||
subJsonFinalMask: z.string(),
|
||||
subJsonMux: z.string(),
|
||||
|
||||
@@ -70,6 +70,7 @@ export class AllSetting {
|
||||
subJsonMux = '';
|
||||
subJsonRules = '';
|
||||
subJsonRoutingRules = '';
|
||||
subJsonDns = '';
|
||||
subJsonFinalMask = '';
|
||||
subJsonObservatory = '';
|
||||
subThemeDir = '';
|
||||
|
||||
@@ -241,6 +241,18 @@ export default function SubscriptionFormatsTab({
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subJsonDns')}
|
||||
description={t('pages.settings.subJsonDnsDesc')}
|
||||
>
|
||||
<Input.TextArea
|
||||
value={allSetting.subJsonDns}
|
||||
placeholder='{"servers": ["https://dns.google/dns-query", "tls://1.1.1.1"]}'
|
||||
onChange={(e) => updateSetting({ subJsonDns: e.target.value })}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</Card>
|
||||
)}
|
||||
{allSetting.subClashEnable && (
|
||||
|
||||
@@ -75,6 +75,7 @@ export const AllSettingSchema = z
|
||||
subJsonMux: z.string().optional(),
|
||||
subJsonRules: z.string().optional(),
|
||||
subJsonRoutingRules: z.string().optional(),
|
||||
subJsonDns: z.string().optional(),
|
||||
subJsonFinalMask: z.string().optional(),
|
||||
subJsonObservatory: z.string().optional(),
|
||||
subHideSettings: z.boolean().optional(),
|
||||
|
||||
@@ -107,6 +107,7 @@ type subControllerConfig struct {
|
||||
subJsonMux string
|
||||
subJsonRules string
|
||||
subJsonRoutingRules string
|
||||
subJsonDns string
|
||||
subJsonFinalMask string
|
||||
subJsonObservatory string
|
||||
subClashEnableRouting bool
|
||||
@@ -191,6 +192,10 @@ func WithSUBJsonRoutingRules(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonRoutingRules = value }
|
||||
}
|
||||
|
||||
func WithSUBJsonDns(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonDns = value }
|
||||
}
|
||||
|
||||
func WithSUBJsonFinalMask(value string) SUBControllerOption {
|
||||
return func(config *subControllerConfig) { config.subJsonFinalMask = value }
|
||||
}
|
||||
@@ -268,6 +273,7 @@ func NewSUBController(g *gin.RouterGroup, options ...SUBControllerOption) *SUBCo
|
||||
sub := NewSubService(config.remarkTemplate)
|
||||
subJsonSvc := NewSubJsonService(config.subJsonMux, config.subJsonRules, config.subJsonFinalMask, config.subJsonRoutingRules, sub)
|
||||
subJsonSvc.SetObservatoryConfig(config.subJsonObservatory)
|
||||
subJsonSvc.SetDnsConfig(config.subJsonDns)
|
||||
a := &SUBController{
|
||||
subTitle: config.subTitle,
|
||||
subSupportUrl: config.subSupportURL,
|
||||
@@ -932,8 +938,8 @@ func (a *SUBController) ApplyCommonHeaders(
|
||||
|
||||
rules, remote, routingErr := resolveRoutingSource(remoteRoutingHapp, profileRoutingRules)
|
||||
if strings.TrimSpace(profileRoutingRules) == "" {
|
||||
// Happ/INCY fetch the geo files the baked JSON rules reference through
|
||||
// this header, so a blank Happ setting falls back to the JSON profile.
|
||||
// Happ/INCY fetch the geo files the baked rules reference through this
|
||||
// header; unlike the documents, it keeps the profile's own DNS servers.
|
||||
rules, remote, routingErr = jsonRoutingHeaderSource(a.subJsonRoutingRules), false, nil
|
||||
}
|
||||
// The off values undo a previously pushed setting, so they ride the same
|
||||
|
||||
20
internal/sub/json_dns.go
Normal file
20
internal/sub/json_dns.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray/dnsconf"
|
||||
)
|
||||
|
||||
// The subJsonDns setting holds either a full xray dns block or a bare array of
|
||||
// servers; dnsconf validates it against the parser the client runs.
|
||||
|
||||
// SetDnsConfig overrides the dns block of every emitted document; an unusable
|
||||
// value is logged and ignored so a typo cannot take subscriptions down.
|
||||
func (s *SubJsonService) SetDnsConfig(raw string) {
|
||||
block, err := dnsconf.Parse(raw)
|
||||
if err != nil {
|
||||
logger.Warningf("subJsonDns: %v; keeping the template DNS", err)
|
||||
return
|
||||
}
|
||||
s.dnsBlock = block
|
||||
}
|
||||
48
internal/sub/json_dns_controller_test.go
Normal file
48
internal/sub/json_dns_controller_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// The panel setting must survive the controller wiring, not just the service
|
||||
// API: sub.go passes it as a controller option.
|
||||
func TestJsonEndpointServesPanelDnsServers(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "tcpin", 4910, 1, dnsTestStream)
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
NewSUBController(
|
||||
router.Group("/"),
|
||||
WithSUBJsonEnabled(true),
|
||||
WithSUBJsonAlwaysArray(true),
|
||||
WithSUBJsonDns(`["https://dns.google/dns-query", "tls://1.1.1.1"]`),
|
||||
)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://sub.example.com/json/s1", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
if resp.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", resp.Code, resp.Body.String())
|
||||
}
|
||||
docs := parseSubJsonDocs(t, resp.Body.String())
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("docs = %d, want 1", len(docs))
|
||||
}
|
||||
servers, _ := docDnsBlock(t, docs[0])["servers"].([]any)
|
||||
if len(servers) != 2 || servers[0] != "https://dns.google/dns-query" || servers[1] != "tls://1.1.1.1" {
|
||||
t.Fatalf("dns servers = %v", servers)
|
||||
}
|
||||
if _, hasTemplate := docDnsBlock(t, docs[0])["tag"]; hasTemplate {
|
||||
t.Fatalf("template dns keys leaked: %v", docDnsBlock(t, docs[0]))
|
||||
}
|
||||
if body := resp.Body.String(); strings.Contains(body, "8.8.8.8") {
|
||||
t.Fatalf("template resolver survived the override:\n%s", body)
|
||||
}
|
||||
}
|
||||
267
internal/sub/json_dns_test.go
Normal file
267
internal/sub/json_dns_test.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
const dnsTestStream = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`
|
||||
|
||||
func docDnsBlock(t *testing.T, doc map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
dns, _ := doc["dns"].(map[string]any)
|
||||
if dns == nil {
|
||||
t.Fatalf("doc has no dns block: %v", doc["dns"])
|
||||
}
|
||||
return dns
|
||||
}
|
||||
|
||||
func onlySubJsonDoc(t *testing.T, js *SubJsonService, subId string) map[string]any {
|
||||
t.Helper()
|
||||
out, _, err := js.GetJson(subId, "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
docs := parseSubJsonDocs(t, out)
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
|
||||
}
|
||||
return docs[0]
|
||||
}
|
||||
|
||||
// A bare servers array must replace the template resolver, not append to it.
|
||||
func TestSubJsonDns_ArrayReplacesTemplateServers(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "tcpin", 4901, 1, dnsTestStream)
|
||||
|
||||
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
||||
js.SetDnsConfig(`["https://dns.google/dns-query", {"address": "tls://1.1.1.1", "domains": ["geosite:youtube"]}]`)
|
||||
|
||||
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
|
||||
servers, _ := dns["servers"].([]any)
|
||||
if len(servers) != 2 {
|
||||
t.Fatalf("servers = %v, want 2", servers)
|
||||
}
|
||||
if servers[0] != "https://dns.google/dns-query" {
|
||||
t.Fatalf("servers[0] = %v", servers[0])
|
||||
}
|
||||
second, _ := servers[1].(map[string]any)
|
||||
if second["address"] != "tls://1.1.1.1" {
|
||||
t.Fatalf("servers[1] = %v", second)
|
||||
}
|
||||
if domains, _ := second["domains"].([]any); strings.Join(stringify(domains), ",") != "geosite:youtube" {
|
||||
t.Fatalf("servers[1].domains = %v", second["domains"])
|
||||
}
|
||||
if _, hasTemplate := dns["tag"]; hasTemplate {
|
||||
t.Fatalf("template dns keys leaked into the override: %v", dns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonDns_ObjectReplacesWholeBlock(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "tcpin", 4902, 1, dnsTestStream)
|
||||
|
||||
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
||||
js.SetDnsConfig(`{"tag":"panel_dns","queryStrategy":"UseIPv4","disableCache":true,"hosts":{"example.com":"1.2.3.4"},"servers":[{"address":"1.1.1.1","skipFallback":true}]}`)
|
||||
|
||||
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
|
||||
if dns["tag"] != "panel_dns" || dns["queryStrategy"] != "UseIPv4" || dns["disableCache"] != true {
|
||||
t.Fatalf("dns header = %v", dns)
|
||||
}
|
||||
hosts, _ := dns["hosts"].(map[string]any)
|
||||
if hosts["example.com"] != "1.2.3.4" {
|
||||
t.Fatalf("dns hosts = %v", dns["hosts"])
|
||||
}
|
||||
servers, _ := dns["servers"].([]any)
|
||||
if len(servers) != 1 {
|
||||
t.Fatalf("servers = %v", servers)
|
||||
}
|
||||
server, _ := servers[0].(map[string]any)
|
||||
if server["address"] != "1.1.1.1" || server["skipFallback"] != true {
|
||||
t.Fatalf("server = %v", server)
|
||||
}
|
||||
}
|
||||
|
||||
// The explicit panel DNS block wins over the profile's, while the profile keeps
|
||||
// owning the routing rules.
|
||||
func TestSubJsonDns_OverridesRoutingProfileDns(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "tcpin", 4903, 1, dnsTestStream)
|
||||
|
||||
js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
|
||||
js.SetDnsConfig(`["9.9.9.9"]`)
|
||||
|
||||
doc := onlySubJsonDoc(t, js, "s1")
|
||||
dns := docDnsBlock(t, doc)
|
||||
servers, _ := dns["servers"].([]any)
|
||||
if len(servers) != 1 || servers[0] != "9.9.9.9" {
|
||||
t.Fatalf("servers = %v, want the panel override only", servers)
|
||||
}
|
||||
if hosts, _ := dns["hosts"].(map[string]any); len(hosts) != 0 {
|
||||
t.Fatalf("profile dns hosts survived the override: %v", hosts)
|
||||
}
|
||||
want := "domain->block,domain->proxy,domain->direct,ip->direct,network->proxy"
|
||||
if got := strings.Join(ruleSignatures(t, doc), ","); got != want {
|
||||
t.Fatalf("rules = %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonDns_InvalidSettingKeepsTemplateDns(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"malformed JSON", `{"servers": [`},
|
||||
{"bare string", `"8.8.8.8"`},
|
||||
{"servers not an array", `{"servers": "8.8.8.8"}`},
|
||||
{"entry without address", `[{"domains": ["geosite:youtube"]}]`},
|
||||
{"non-string entry", `[53]`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "tcpin", 4904, 1, dnsTestStream)
|
||||
|
||||
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
||||
js.SetDnsConfig(tc.value)
|
||||
|
||||
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
|
||||
if dns["tag"] != "dns_out" || dns["queryStrategy"] != "UseIP" {
|
||||
t.Fatalf("template dns header = %v", dns)
|
||||
}
|
||||
servers, _ := dns["servers"].([]any)
|
||||
if len(servers) != 1 {
|
||||
t.Fatalf("template servers = %v", servers)
|
||||
}
|
||||
first, _ := servers[0].(map[string]any)
|
||||
if first["address"] != "8.8.8.8" {
|
||||
t.Fatalf("template server = %v", first)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubJsonDns_BlankKeepsTemplateDns(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "tcpin", 4905, 1, dnsTestStream)
|
||||
|
||||
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
||||
js.SetDnsConfig(" ")
|
||||
|
||||
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
|
||||
servers, _ := dns["servers"].([]any)
|
||||
first, _ := servers[0].(map[string]any)
|
||||
if first["address"] != "8.8.8.8" {
|
||||
t.Fatalf("template server = %v", first)
|
||||
}
|
||||
}
|
||||
|
||||
// Balancer documents are built from the same template, so they carry the
|
||||
// override too.
|
||||
func TestSubJsonDns_AppliesToBalancerDocuments(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
tcp := seedSubInbound(t, "s1", "tcpin", 4906, 1, dnsTestStream)
|
||||
seedSubBalancer(t, &model.SubBalancer{
|
||||
Remark: "auto", Strategy: "random", InboundIds: []int{tcp.Id}, SortOrder: 1, Enabled: true,
|
||||
})
|
||||
|
||||
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
||||
js.SetDnsConfig(`["https://dns.google/dns-query"]`)
|
||||
|
||||
out, _, err := js.GetJson("s1", "req.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
balancerDoc := findDocByRemarks(parseSubJsonDocs(t, out), "auto")
|
||||
if balancerDoc == nil {
|
||||
t.Fatalf("balancer doc missing:\n%s", out)
|
||||
}
|
||||
servers, _ := docDnsBlock(t, balancerDoc)["servers"].([]any)
|
||||
if len(servers) != 1 || servers[0] != "https://dns.google/dns-query" {
|
||||
t.Fatalf("balancer dns servers = %v", servers)
|
||||
}
|
||||
}
|
||||
|
||||
// The validator rejects a block whose types xray cannot decode, even when the
|
||||
// servers list itself looks fine.
|
||||
func TestSubJsonDns_BrokenBlockKeepsTemplateDns(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "tcpin", 4907, 1, dnsTestStream)
|
||||
|
||||
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
||||
js.SetDnsConfig(`{"servers": ["1.1.1.1"], "hosts": 5}`)
|
||||
|
||||
dns := docDnsBlock(t, onlySubJsonDoc(t, js, "s1"))
|
||||
servers, _ := dns["servers"].([]any)
|
||||
first, _ := servers[0].(map[string]any)
|
||||
if len(servers) != 1 || first["address"] != "8.8.8.8" {
|
||||
t.Fatalf("template dns = %v", dns)
|
||||
}
|
||||
}
|
||||
|
||||
// An unusable routing profile degrades to an empty spec; the DNS override must
|
||||
// still reach the document.
|
||||
func TestSubJsonDns_AppliesWhenRoutingProfileUnusable(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
seedSubInbound(t, "s1", "tcpin", 4908, 1, dnsTestStream)
|
||||
|
||||
js := NewSubJsonService("", "", "", "not-a-routing-payload", NewSubService(""))
|
||||
js.SetDnsConfig(`["9.9.9.9"]`)
|
||||
|
||||
doc := onlySubJsonDoc(t, js, "s1")
|
||||
servers, _ := docDnsBlock(t, doc)["servers"].([]any)
|
||||
if len(servers) != 1 || servers[0] != "9.9.9.9" {
|
||||
t.Fatalf("dns servers = %v", servers)
|
||||
}
|
||||
want := "network->proxy"
|
||||
if got := strings.Join(ruleSignatures(t, doc), ","); got != want {
|
||||
t.Fatalf("rules = %v, want the plain template rule %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The dummy info node is emitted as a document too, so it carries the panel DNS.
|
||||
func TestSubJsonDns_AppliesToInfoNodeDocument(t *testing.T) {
|
||||
setupInfoNodeTestDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
ib := &model.Inbound{
|
||||
Id: 1, UserId: 1, Remark: "Germany-VLESS", Enable: true, Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
Settings: `{"clients":[{"id":"c1-uuid","email":"user1@test.com","subId":"sub-json","enable":true,"totalGB":10737418240}]}`,
|
||||
StreamSettings: `{"network":"tcp","security":"none"}`,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.ClientRecord{Id: 1, Email: "user1@test.com", SubID: "sub-json", UUID: "c1-uuid", Enable: true, TotalGB: 10737418240}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{InboundId: 1, ClientId: 1}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: "user1@test.com", Up: 1073741824, Down: 1073741824, Total: 10737418240, Enable: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sub := NewSubService("{{EMAIL}}|📊{{TRAFFIC_LEFT}}")
|
||||
sub.subInfoNodeEnable = true
|
||||
js := NewSubJsonService("", "", "", "", sub)
|
||||
js.SetDnsConfig(`["https://dns.google/dns-query"]`)
|
||||
|
||||
out, _, err := js.GetJson("sub-json", "sub.example.com", true)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
docs := parseSubJsonDocs(t, out)
|
||||
if len(docs) != 2 {
|
||||
t.Fatalf("docs = %d, want info node + inbound:\n%s", len(docs), out)
|
||||
}
|
||||
servers, _ := docDnsBlock(t, docs[0])["servers"].([]any)
|
||||
if len(servers) != 1 || servers[0] != "https://dns.google/dns-query" {
|
||||
t.Fatalf("info node dns servers = %v", servers)
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ type SubJsonService struct {
|
||||
bakedRoutingMu sync.Mutex
|
||||
bakedRouting *bakedRoutingState
|
||||
|
||||
// dnsBlock is the panel DNS override, fixed for the service's lifetime.
|
||||
dnsBlock map[string]any
|
||||
|
||||
SubService *SubService
|
||||
}
|
||||
|
||||
@@ -83,7 +86,7 @@ func NewSubJsonService(mux string, rules string, finalMask string, routingRules
|
||||
// Re-resolved per call so an upstream edit reaches the documents without a
|
||||
// restart; a failed resolve keeps the last good template.
|
||||
func (s *SubJsonService) bakedTemplate() map[string]any {
|
||||
if s.routingRules == "" {
|
||||
if s.routingRules == "" && s.dnsBlock == nil {
|
||||
return s.configJson
|
||||
}
|
||||
spec := resolveJsonRoutingSpec(s.routingRules)
|
||||
@@ -93,12 +96,19 @@ func (s *SubJsonService) bakedTemplate() map[string]any {
|
||||
if spec.empty() || spec.equal(s.bakedRouting.spec) {
|
||||
return s.bakedRouting.configJson
|
||||
}
|
||||
} else if spec.empty() {
|
||||
} else if spec.empty() && s.dnsBlock == nil {
|
||||
return s.configJson
|
||||
}
|
||||
template := make(map[string]any, len(s.configJson)+2)
|
||||
maps.Copy(template, s.configJson)
|
||||
applyJsonRouting(template, spec)
|
||||
if !spec.empty() {
|
||||
applyJsonRouting(template, spec)
|
||||
}
|
||||
// The panel-level DNS block is an explicit choice, so it also replaces the
|
||||
// dns subtree a routing profile would otherwise bake in.
|
||||
if s.dnsBlock != nil {
|
||||
template["dns"] = s.dnsBlock
|
||||
}
|
||||
s.bakedRouting = &bakedRoutingState{spec: spec, configJson: template}
|
||||
return template
|
||||
}
|
||||
@@ -1041,6 +1051,9 @@ func (s *SubJsonService) genDummySocksConfig(remark string) json_util.RawMessage
|
||||
|
||||
newConfigJson := make(map[string]any)
|
||||
maps.Copy(newConfigJson, s.configJson)
|
||||
if s.dnsBlock != nil {
|
||||
newConfigJson["dns"] = s.dnsBlock
|
||||
}
|
||||
newConfigJson["outbounds"] = newOutbounds
|
||||
newConfigJson["remarks"] = remark
|
||||
|
||||
|
||||
@@ -155,6 +155,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
SubJsonRoutingRules = ""
|
||||
}
|
||||
|
||||
SubJsonDns, err := s.settingService.GetSubJsonDns()
|
||||
if err != nil {
|
||||
SubJsonDns = ""
|
||||
}
|
||||
|
||||
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
|
||||
if err != nil {
|
||||
SubJsonFinalMask = ""
|
||||
@@ -316,6 +321,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||
WithSUBJsonMux(SubJsonMux),
|
||||
WithSUBJsonRules(SubJsonRules),
|
||||
WithSUBJsonRoutingRules(SubJsonRoutingRules),
|
||||
WithSUBJsonDns(SubJsonDns),
|
||||
WithSUBJsonFinalMask(SubJsonFinalMask),
|
||||
WithSUBJsonObservatory(SubJsonObservatory),
|
||||
WithSUBClashEnableRouting(SubClashEnableRouting),
|
||||
|
||||
@@ -108,6 +108,7 @@ type AllSetting struct {
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
SubJsonRoutingRules string `json:"subJsonRoutingRules" form:"subJsonRoutingRules"`
|
||||
SubJsonDns string `json:"subJsonDns" form:"subJsonDns"`
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
|
||||
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
|
||||
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
|
||||
|
||||
@@ -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":
|
||||
|
||||
64
internal/web/service/setting_sub_json_dns_test.go
Normal file
64
internal/web/service/setting_sub_json_dns_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) لاختيار صيغة Xray JSON تلقائيًا على رابط الاشتراك القياسي. فارغ افتراضيًا، لذا يبقى الاكتشاف التلقائي معطلاً حتى تحدد نمطًا للعملاء الذين تريد خدمتهم. يحصل العملاء الآخرون على الاستجابة الخام/Base64. أعد تشغيل اللوحة بعد التغيير.",
|
||||
"subJsonRoutingRules": "ملف توجيه اشتراك JSON",
|
||||
"subJsonRoutingRulesDesc": "JSON لقواعد التوجيه، أو رابط happ:// أو incy://، أو رابط https://. عند تعيينه، يستبدل DNS وقواعد التوجيه لاشتراك JSON (تُدمج في الإعداد للعملاء الذين يتجاهلون الترويسات، مثل Happ وINCY مع الموازنات من جانب العميل). إذا كان إعداد ترويسة Happ فارغًا، يُرسَل نفس الملف تلقائيًا كترويسة Routing (يُحوَّل JSON والروابط إلى ديبلينك happ://) ليحمّل العملاء ملفات geo المطابقة.",
|
||||
"subJsonDns": "خوادم DNS",
|
||||
"subJsonDnsDesc": "يستبدل كتلة dns في جميع إعدادات اشتراك JSON: إما كائن dns كامل أو مصفوفة servers فقط. يستبدل أيضًا DNS ملف التوجيه داخل هذه الإعدادات؛ أما العملاء الذين يطبّقون ترويسة Routing فيُبقون على مُحلِّلات الملف. تركه فارغًا يُبقي المُحلِّل المدمج.",
|
||||
"subClashAutoDetect": "اكتشاف عملاء Clash/Mihomo تلقائيًا",
|
||||
"subClashAutoDetectDesc": "عند التفعيل، يتلقى عملاء Clash/Mihomo المعروفون الذين يطلبون رابط الاشتراك القياسي إعداد Clash بصيغة YAML تلقائيًا. تستمر المتصفحات في عرض صفحة الاشتراك، ويحصل العملاء الآخرون على الاستجابة الخام/Base64، وتبقى روابط JSON وClash الصريحة متاحة. يتطلب تفعيل اشتراك Clash/Mihomo وإعادة تشغيل اللوحة لتطبيق التغيير.",
|
||||
"subClashUserAgentRegex": "تعبير User-Agent لعملاء Clash/Mihomo",
|
||||
|
||||
@@ -1300,6 +1300,8 @@
|
||||
"subJsonUserAgentRegexDesc": "Go RE2 regular expression matched against the client's User-Agent to auto-select the Xray JSON format on the standard subscription URL. Empty by default, so auto-detection stays off until you set a pattern for the clients you want to serve. Other clients keep the raw/base64 response. Restart the panel after changes.",
|
||||
"subJsonRoutingRules": "JSON subscription routing profile",
|
||||
"subJsonRoutingRulesDesc": "Routing JSON, happ:// or incy:// deeplink, or https:// URL. When set, it replaces the JSON subscription's DNS and routing rules (baked into the config for clients that ignore headers, e.g. Happ and INCY with client-side balancers). If the Happ routing header setting is empty, the same profile is automatically sent as the Routing header (inline JSON and URLs as a happ:// deeplink) so clients fetch the matching geo files.",
|
||||
"subJsonDns": "DNS servers",
|
||||
"subJsonDnsDesc": "Overrides the dns block baked into every JSON subscription config: a full dns object or a servers array. It also replaces the routing profile's DNS in those configs; clients that apply the Routing header keep the profile's resolvers instead. Empty keeps the built-in resolver.",
|
||||
"subClashAutoDetect": "Auto-detect Clash/Mihomo clients",
|
||||
"subClashAutoDetectDesc": "When enabled, recognized Clash/Mihomo clients requesting the standard subscription URL receive Clash YAML automatically. Browsers still show the subscription page, other clients keep the raw/base64 response, and the explicit JSON and Clash URLs remain available. Requires Clash/Mihomo subscription to be enabled and a panel restart to apply.",
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent regex",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "Expresión regular Go RE2 que se compara con el User-Agent del cliente para seleccionar automáticamente el formato Xray JSON en la URL de suscripción estándar. Vacía de forma predeterminada, por lo que la detección automática permanece desactivada hasta que definas un patrón para los clientes que quieras atender. Los demás clientes conservan la respuesta sin procesar/Base64. Reinicia el panel después de cambiarla.",
|
||||
"subJsonRoutingRules": "Perfil de enrutamiento para la suscripción JSON",
|
||||
"subJsonRoutingRulesDesc": "JSON de enrutamiento, deeplink happ:// o incy://, o URL https://. Si se establece, reemplaza el DNS y las reglas de enrutamiento de la suscripción JSON (integrados en la configuración para clientes que ignoran cabeceras, p. ej. Happ e INCY con balanceadores del lado cliente). Si el ajuste de cabecera de Happ está vacío, el mismo perfil se envía automáticamente como cabecera Routing (el JSON y las URL como deeplink happ://) para que los clientes descarguen los archivos geo coincidentes.",
|
||||
"subJsonDns": "Servidores DNS",
|
||||
"subJsonDnsDesc": "Reemplaza el bloque dns de todas las configuraciones de la suscripción JSON: un objeto dns completo o solo el array servers. También reemplaza el DNS del perfil de enrutamiento en esas configuraciones; los clientes que aplican la cabecera Routing conservan sus resolutores. Vacío mantiene el resolutor integrado.",
|
||||
"subClashAutoDetect": "Detectar automáticamente clientes Clash/Mihomo",
|
||||
"subClashAutoDetectDesc": "Al activarlo, los clientes Clash/Mihomo reconocidos que soliciten la URL de suscripción estándar recibirán automáticamente YAML de Clash. Los navegadores seguirán mostrando la página de suscripción, los demás clientes conservarán la respuesta sin procesar/Base64 y las URL explícitas de JSON y Clash seguirán disponibles. Requiere activar la suscripción Clash/Mihomo y reiniciar el panel para aplicar el cambio.",
|
||||
"subClashUserAgentRegex": "Expresión User-Agent de Clash/Mihomo",
|
||||
|
||||
@@ -1182,6 +1182,8 @@
|
||||
"subJsonUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده میشود تا صیغه Xray JSON روی آدرس استاندارد اشتراک بهطور خودکار انتخاب شود. بهطور پیشفرض خالی است، بنابراین تشخیص خودکار غیرفعال میماند تا زمانی که الگویی برای کلاینتهای موردنظر خود تعیین کنید. سایر کلاینتها پاسخ خام/Base64 را دریافت میکنند. پس از تغییر، پنل را راهاندازی مجدد کنید.",
|
||||
"subJsonRoutingRules": "پروفایل مسیریابی اشتراک JSON",
|
||||
"subJsonRoutingRulesDesc": "JSON قواعد مسیریابی، دیپلینک happ:// یا incy://، یا آدرس https://. وقتی تنظیم شود، DNS و قواعد مسیریابی اشتراک JSON را جایگزین میکند (در پیکربندی برای کلاینتهایی که هدرها را نادیده میگیرند تعبیه میشود، مانند Happ و INCY با بالانسر سمت کلاینت). اگر تنظیم هدر Happ خالی باشد، همین پروفایل بهصورت خودکار در هدر Routing ارسال میشود (JSON و آدرسها بهصورت دیپلینک happ://) تا کلاینتها فایلهای geo منطبق را دانلود کنند.",
|
||||
"subJsonDns": "سرورهای DNS",
|
||||
"subJsonDnsDesc": "بلوک dns را که در کلاینتهای اشتراک JSON جای میگیرد جایگزین میکند: یا یک شیء کامل dns یا فقط آرایهٔ servers. همچنین DNS پروفایل مسیریابی را در همان کلاینتها جایگزین میکند؛ اما کلاینتهایی که هدر Routing را اعمال میکنند، ریزالورهای پروفایل را نگه میدارند. خالی یعنی ریزالور پیشفرض.",
|
||||
"subClashAutoDetect": "تشخیص خودکار کلاینتهای Clash/Mihomo",
|
||||
"subClashAutoDetectDesc": "با فعالسازی، کلاینتهای شناختهشده Clash/Mihomo که آدرس استاندارد اشتراک را درخواست میکنند، بهطور خودکار پیکربندی Clash با فرمت YAML دریافت میکنند. مرورگرها همچنان صفحه اشتراک را نمایش میدهند، سایر کلاینتها پاسخ خام/Base64 را دریافت میکنند و آدرسهای صریح JSON و Clash در دسترس میمانند. برای اعمال تغییر، اشتراک Clash/Mihomo باید فعال باشد و پنل راهاندازی مجدد شود.",
|
||||
"subClashUserAgentRegex": "عبارت User-Agent برای Clash/Mihomo",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "Ekspresi reguler Go RE2 yang dicocokkan dengan User-Agent klien untuk memilih format Xray JSON secara otomatis pada URL langganan standar. Kosong secara bawaan, sehingga deteksi otomatis tetap nonaktif hingga Anda menetapkan pola untuk klien yang ingin dilayani. Klien lain tetap menerima respons mentah/Base64. Mulai ulang panel setelah mengubahnya.",
|
||||
"subJsonRoutingRules": "Profil routing langganan JSON",
|
||||
"subJsonRoutingRulesDesc": "JSON routing, deeplink happ:// atau incy://, atau URL https://. Jika diisi, menggantikan DNS dan aturan routing langganan JSON (dibake ke dalam konfigurasi untuk klien yang mengabaikan header, mis. Happ dan INCY dengan balancer sisi klien). Jika pengaturan header Happ kosong, profil yang sama otomatis dikirim sebagai header Routing (JSON dan URL sebagai deeplink happ://) agar klien mengunduh berkas geo yang cocok.",
|
||||
"subJsonDns": "Server DNS",
|
||||
"subJsonDnsDesc": "Mengganti blok dns di semua konfigurasi langganan JSON: objek dns lengkap atau hanya array servers. Juga menggantikan DNS profil perutean di konfigurasi tersebut; klien yang menerapkan header Routing tetap memakai resolver profil. Kosongkan untuk memakai resolver bawaan.",
|
||||
"subClashAutoDetect": "Deteksi otomatis klien Clash/Mihomo",
|
||||
"subClashAutoDetectDesc": "Jika diaktifkan, klien Clash/Mihomo yang dikenali dan meminta URL langganan standar akan otomatis menerima YAML Clash. Browser tetap menampilkan halaman langganan, klien lain tetap menerima respons mentah/Base64, dan URL JSON serta Clash eksplisit tetap tersedia. Langganan Clash/Mihomo harus diaktifkan dan panel harus dimulai ulang agar perubahan diterapkan.",
|
||||
"subClashUserAgentRegex": "Regex User-Agent Clash/Mihomo",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "標準サブスクリプション URL で Xray JSON 形式を自動選択するため、クライアントの User-Agent と照合する Go RE2 正規表現です。既定では空欄のため、対象とするクライアント向けのパターンを設定するまで自動判別は無効のままです。その他のクライアントには従来の raw/Base64 応答を返します。変更後にパネルを再起動してください。",
|
||||
"subJsonRoutingRules": "JSON サブスクリプションのルーティングプロファイル",
|
||||
"subJsonRoutingRulesDesc": "ルーティング JSON、happ:// または incy:// のディープリンク、もしくは https:// URL。設定すると JSON サブスクリプションの DNS とルーティングルールを置き換えます(ヘッダーを無視するクライアント、例えばクライアントサイドバランサーを使う Happ や INCY 向けに設定へ組み込まれます)。Happ のルーティングヘッダー設定が空の場合、同じプロファイルが自動的に Routing ヘッダーとして送信され(JSON と URL は happ:// ディープリンクに変換)、クライアントは対応する geo ファイルを取得します。",
|
||||
"subJsonDns": "DNSサーバー",
|
||||
"subJsonDnsDesc": "JSONサブスクリプションの各設定に埋め込まれる dns ブロックを置き換えます。完全な dns オブジェクトか servers 配列のみを指定できます。設定内のルーティングプロファイルのDNSも置き換えますが、Routing ヘッダーを適用するクライアントはプロファイルのリゾルバーを使います。空欄の場合は組み込みのリゾルバーを使用します。",
|
||||
"subClashAutoDetect": "Clash/Mihomo クライアントを自動検出",
|
||||
"subClashAutoDetectDesc": "有効にすると、標準サブスクリプション URL を要求する既知の Clash/Mihomo クライアントへ Clash YAML を自動的に返します。ブラウザには引き続きサブスクリプションページを表示し、その他のクライアントには従来の raw/Base64 応答を返します。明示的な JSON および Clash URL も引き続き利用できます。Clash/Mihomo サブスクリプションを有効にし、適用のためにパネルを再起動する必要があります。",
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表現",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "Expressão regular Go RE2 comparada com o User-Agent do cliente para selecionar automaticamente o formato Xray JSON na URL de assinatura padrão. Vazia por padrão, então a detecção automática permanece desativada até você definir um padrão para os clientes que deseja atender. Os outros clientes mantêm a resposta bruta/Base64. Reinicie o painel após alterá-la.",
|
||||
"subJsonRoutingRules": "Perfil de roteamento da assinatura JSON",
|
||||
"subJsonRoutingRulesDesc": "JSON de roteamento, deeplink happ:// ou incy://, ou URL https://. Quando definido, substitui o DNS e as regras de roteamento da assinatura JSON (embutidos na configuração para clientes que ignoram cabeçalhos, ex.: Happ e INCY com balanceadores no lado do cliente). Se a configuração de cabeçalho do Happ estiver vazia, o mesmo perfil é enviado automaticamente como cabeçalho Routing (JSON e URLs como deeplink happ://) para que os clientes baixem os arquivos geo correspondentes.",
|
||||
"subJsonDns": "Servidores DNS",
|
||||
"subJsonDnsDesc": "Substitui o bloco dns de todas as configurações da assinatura JSON: um objeto dns completo ou apenas o array servers. Também substitui o DNS do perfil de roteamento nessas configurações; clientes que aplicam o cabeçalho Routing mantêm os resolvedores do perfil. Vazio mantém o resolvedor integrado.",
|
||||
"subClashAutoDetect": "Detectar clientes Clash/Mihomo automaticamente",
|
||||
"subClashAutoDetectDesc": "Quando ativado, clientes Clash/Mihomo reconhecidos que solicitarem a URL de assinatura padrão receberão automaticamente YAML do Clash. Os navegadores continuarão exibindo a página de assinatura, os outros clientes manterão a resposta bruta/Base64 e as URLs explícitas de JSON e Clash continuarão disponíveis. Requer a assinatura Clash/Mihomo ativada e a reinicialização do painel para aplicar a alteração.",
|
||||
"subClashUserAgentRegex": "Expressão User-Agent do Clash/Mihomo",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для автоматического выбора формата Xray JSON на стандартном URL подписки. По умолчанию пустое, поэтому автоопределение отключено, пока вы не зададите шаблон для нужных клиентов. Остальные клиенты получают ответ raw/Base64. После изменения перезапустите панель.",
|
||||
"subJsonRoutingRules": "Профиль маршрутизации для JSON-подписки",
|
||||
"subJsonRoutingRulesDesc": "JSON правил маршрутизации, диплинк happ:// или incy://, либо https:// URL. Если задан, заменяет DNS и правила маршрутизации JSON-подписки (правила встраиваются в конфиг для клиентов, игнорирующих заголовки, например Happ и INCY с клиентскими балансировщиками). Если настройка заголовка Happ пуста, этот же профиль автоматически отправляется в заголовке Routing (JSON и URL — в виде диплинка happ://), чтобы клиенты скачивали подходящие geo-файлы.",
|
||||
"subJsonDns": "DNS-серверы",
|
||||
"subJsonDnsDesc": "Заменяет блок dns, встраиваемый в конфиги JSON-подписки: полный объект dns или просто массив servers. Также заменяет DNS профиля маршрутизации в этих конфигах; клиенты, применяющие заголовок Routing, используют резолверы из профиля. Пусто — встроенный резолвер.",
|
||||
"subClashAutoDetect": "Автоматически определять клиентов Clash/Mihomo",
|
||||
"subClashAutoDetectDesc": "Если параметр включён, распознанные клиенты Clash/Mihomo при запросе стандартного URL подписки автоматически получают Clash YAML. Браузеры по-прежнему показывают страницу подписки, остальные клиенты получают прежний ответ raw/Base64, а явные URL JSON и Clash остаются доступными. Для применения требуется включить подписку Clash/Mihomo и перезапустить панель.",
|
||||
"subClashUserAgentRegex": "Регулярное выражение User-Agent Clash/Mihomo",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "Standart abonelik URL'sinde Xray JSON biçimini otomatik seçmek için istemcinin User-Agent değeriyle eşleştirilen Go RE2 düzenli ifadesi. Varsayılan olarak boştur; bu nedenle hizmet vermek istediğiniz istemciler için bir desen belirleyene kadar otomatik algılama kapalı kalır. Diğer istemciler ham/Base64 yanıtını almaya devam eder. Değişiklikten sonra paneli yeniden başlatın.",
|
||||
"subJsonRoutingRules": "JSON aboneliği yönlendirme profili",
|
||||
"subJsonRoutingRulesDesc": "Yönlendirme JSON'u, happ:// veya incy:// deeplink'i ya da https:// URL'si. Ayarlandığında JSON aboneliğinin DNS ve yönlendirme kurallarını değiştirir (başlıkları yok sayan istemciler için yapılandırmaya gömülür, örn. istemci tarafı dengeleyicileri olan Happ ve INCY). Happ yönlendirme başlığı ayarı boşsa, aynı profil otomatik olarak Routing başlığı olarak gönderilir (JSON ve URL'ler happ:// deeplink'i olarak) ve istemciler eşleşen geo dosyalarını indirir.",
|
||||
"subJsonDns": "DNS sunucuları",
|
||||
"subJsonDnsDesc": "JSON abonelik yapılandırmalarındaki dns bloğunu değiştirir: tam bir dns nesnesi veya yalnızca servers dizisi. Bu yapılandırmalarda yönlendirme profilinin DNS'ini de değiştirir; Routing başlığını uygulayan istemciler ise profilin çözümleyicilerini kullanır. Boş bırakılırsa yerleşik çözümleyici kullanılır.",
|
||||
"subClashAutoDetect": "Clash/Mihomo istemcilerini otomatik algıla",
|
||||
"subClashAutoDetectDesc": "Etkinleştirildiğinde, standart abonelik URL'sini isteyen tanınmış Clash/Mihomo istemcileri otomatik olarak Clash YAML alır. Tarayıcılar abonelik sayfasını göstermeye devam eder, diğer istemciler ham/Base64 yanıtını almaya devam eder ve açık JSON ile Clash URL'leri kullanılabilir kalır. Uygulanması için Clash/Mihomo aboneliğinin etkinleştirilmesi ve panelin yeniden başlatılması gerekir.",
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent düzenli ifadesi",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для автоматичного вибору формату Xray JSON на стандартній URL-адресі підписки. Типово порожнє, тому автоматичне визначення вимкнено, доки ви не задасте шаблон для потрібних клієнтів. Інші клієнти отримують відповідь raw/Base64. Після зміни перезапустіть панель.",
|
||||
"subJsonRoutingRules": "Профіль маршрутизації для JSON-підписки",
|
||||
"subJsonRoutingRulesDesc": "JSON правил маршрутизації, діплінк happ:// або incy://, або https:// URL. Якщо задано, замінює DNS і правила маршрутизації JSON-підписки (вбудовуються в конфігурацію для клієнтів, що ігнорують заголовки, напр. Happ і INCY з клієнтськими балансувальниками). Якщо налаштування заголовка Happ порожнє, цей самий профіль автоматично надсилається в заголовку Routing (JSON і URL — у вигляді діплінка happ://), щоб клієнти завантажували відповідні geo-файли.",
|
||||
"subJsonDns": "DNS-сервери",
|
||||
"subJsonDnsDesc": "Замінює блок dns, що вбудовується в конфіги JSON-підписки: повний об'єкт dns або просто масив servers. Також замінює DNS профілю маршрутизації в цих конфігах; клієнти, які застосовують заголовок Routing, використовують резолвери з профілю. Порожнє — вбудований резолвер.",
|
||||
"subClashAutoDetect": "Автоматично визначати клієнтів Clash/Mihomo",
|
||||
"subClashAutoDetectDesc": "Якщо параметр увімкнено, розпізнані клієнти Clash/Mihomo під час запиту стандартної URL-адреси підписки автоматично отримують Clash YAML. Браузери й надалі показують сторінку підписки, інші клієнти отримують звичайну відповідь raw/Base64, а явні URL-адреси JSON і Clash залишаються доступними. Для застосування потрібно ввімкнути підписку Clash/Mihomo та перезапустити панель.",
|
||||
"subClashUserAgentRegex": "Регулярний вираз User-Agent Clash/Mihomo",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "Biểu thức chính quy Go RE2 được so khớp với User-Agent của ứng dụng để tự động chọn định dạng Xray JSON trên URL đăng ký tiêu chuẩn. Mặc định để trống, nên tính năng tự động nhận diện vẫn tắt cho đến khi bạn đặt mẫu cho các ứng dụng muốn phục vụ. Các ứng dụng khác tiếp tục nhận phản hồi thô/Base64. Khởi động lại bảng điều khiển sau khi thay đổi.",
|
||||
"subJsonRoutingRules": "Hồ sơ định tuyến cho đăng ký JSON",
|
||||
"subJsonRoutingRulesDesc": "JSON định tuyến, deeplink happ:// hoặc incy://, hoặc URL https://. Khi được đặt, nó thay thế DNS và quy tắc định tuyến của đăng ký JSON (được nhúng vào cấu hình cho các client bỏ qua header, ví dụ Happ và INCY với bộ cân bằng phía client). Nếu cài đặt header của Happ để trống, chính hồ sơ đó sẽ được tự động gửi dưới dạng header Routing (JSON và URL thành deeplink happ://) để client tải các tệp geo khớp.",
|
||||
"subJsonDns": "Máy chủ DNS",
|
||||
"subJsonDnsDesc": "Thay thế khối dns trong mọi cấu hình đăng ký JSON: đối tượng dns đầy đủ hoặc chỉ mảng servers. Cũng thay thế DNS của hồ sơ định tuyến trong các cấu hình đó; ứng dụng áp dụng tiêu đề Routing vẫn dùng bộ phân giải của hồ sơ. Để trống sẽ giữ trình phân giải tích hợp.",
|
||||
"subClashAutoDetect": "Tự động nhận diện ứng dụng Clash/Mihomo",
|
||||
"subClashAutoDetectDesc": "Khi bật, các ứng dụng Clash/Mihomo được nhận diện khi yêu cầu URL đăng ký tiêu chuẩn sẽ tự động nhận cấu hình Clash YAML. Trình duyệt vẫn hiển thị trang đăng ký, các ứng dụng khác tiếp tục nhận phản hồi thô/Base64, còn các URL JSON và Clash riêng vẫn khả dụng. Cần bật đăng ký Clash/Mihomo và khởi động lại bảng điều khiển để áp dụng thay đổi.",
|
||||
"subClashUserAgentRegex": "Biểu thức User-Agent Clash/Mihomo",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上自动选择 Xray JSON 格式的 Go RE2 正则表达式。默认留空,因此在为需要服务的客户端设置规则之前,自动识别保持关闭。其他客户端继续获得原始/Base64 响应。更改后请重启面板。",
|
||||
"subJsonRoutingRules": "JSON 订阅路由配置",
|
||||
"subJsonRoutingRulesDesc": "路由 JSON、happ:// 或 incy:// 深度链接,或 https:// URL。设置后,将替换 JSON 订阅的 DNS 和路由规则(为忽略响应头的客户端直接嵌入配置,例如使用客户端侧负载均衡的 Happ 和 INCY)。若 Happ 路由响应头设置为空,同一配置会自动作为 Routing 响应头发送(内联 JSON 和 URL 转换为 happ:// 深度链接),以便客户端下载匹配的 geo 文件。",
|
||||
"subJsonDns": "DNS 服务器",
|
||||
"subJsonDnsDesc": "替换写入每个 JSON 订阅配置的 dns 块:可以是完整的 dns 对象,也可以只是 servers 数组。这些配置中路由配置文件的 DNS 也会被替换;但应用 Routing 头的客户端仍使用配置文件中的解析器。留空则使用内置解析器。",
|
||||
"subClashAutoDetect": "自动识别 Clash/Mihomo 客户端",
|
||||
"subClashAutoDetectDesc": "启用后,使用标准订阅 URL 的已识别 Clash/Mihomo 客户端将自动获得 Clash YAML。浏览器仍显示订阅页面,其他客户端继续获得原始/Base64 响应,独立的 JSON 和 Clash URL 仍然可用。需要启用 Clash/Mihomo 订阅并重启面板才能生效。",
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",
|
||||
|
||||
@@ -1178,6 +1178,8 @@
|
||||
"subJsonUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上自動選擇 Xray JSON 格式的 Go RE2 正規表示式。預設留空,因此在為要服務的用戶端設定規則之前,自動辨識會保持關閉。其他用戶端繼續取得原始/Base64 回應。變更後請重新啟動面板。",
|
||||
"subJsonRoutingRules": "JSON 訂閱路由設定檔",
|
||||
"subJsonRoutingRulesDesc": "路由 JSON、happ:// 或 incy:// 深層連結,或 https:// URL。設定後,將取代 JSON 訂閱的 DNS 與路由規則(為忽略回應標頭的用戶端直接內嵌至設定,例如使用用戶端負載平衡器的 Happ 與 INCY)。若 Happ 路由標頭設定為空,同一設定檔會自動作為 Routing 標頭傳送(內嵌 JSON 與 URL 轉換為 happ:// 深層連結),讓用戶端下載相符的 geo 檔案。",
|
||||
"subJsonDns": "DNS 伺服器",
|
||||
"subJsonDnsDesc": "替換寫入每個 JSON 訂閱設定的 dns 區塊:可以是完整的 dns 物件,也可以只是 servers 陣列。這些設定中路由設定檔的 DNS 也會被替換;但套用 Routing 標頭的用戶端仍使用設定檔中的解析器。留空則使用內建解析器。",
|
||||
"subClashAutoDetect": "自動識別 Clash/Mihomo 用戶端",
|
||||
"subClashAutoDetectDesc": "啟用後,使用標準訂閱 URL 的已識別 Clash/Mihomo 用戶端將自動取得 Clash YAML。瀏覽器仍會顯示訂閱頁面,其他用戶端繼續取得原始/Base64 回應,獨立的 JSON 和 Clash URL 仍可使用。需要啟用 Clash/Mihomo 訂閱並重新啟動面板才能生效。",
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",
|
||||
|
||||
119
internal/xray/dnsconf/dnsconf.go
Normal file
119
internal/xray/dnsconf/dnsconf.go
Normal file
@@ -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
|
||||
}
|
||||
68
internal/xray/dnsconf/dnsconf_test.go
Normal file
68
internal/xray/dnsconf/dnsconf_test.go
Normal file
@@ -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