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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user