From a62c637632d0e4f0ff74fe185a3ea50048cb73d8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 26 Apr 2026 17:34:31 +0200 Subject: [PATCH] DNS outbound: Add rules --- web/assets/js/model/outbound.js | 143 ++++++++++++++++++++++++++++++-- web/html/form/outbound.html | 79 ++++++++++++++---- web/service/inbound.go | 2 +- web/web.go | 9 +- 4 files changed, 209 insertions(+), 24 deletions(-) diff --git a/web/assets/js/model/outbound.js b/web/assets/js/model/outbound.js index 97602815..8db9d8e2 100644 --- a/web/assets/js/model/outbound.js +++ b/web/assets/js/model/outbound.js @@ -97,6 +97,74 @@ const Address_Port_Strategy = { TxtPortAndAddress: "txtportandaddress" }; +const DNSRuleActions = ['direct', 'drop', 'reject', 'hijack']; + +function normalizeDNSRuleField(value) { + if (value === null || value === undefined) { + return ''; + } + if (Array.isArray(value)) { + return value.map(item => item.toString().trim()).filter(item => item.length > 0).join(','); + } + return value.toString().trim(); +} + +function normalizeDNSRuleAction(action) { + action = ObjectUtil.isEmpty(action) ? 'direct' : action.toString().toLowerCase().trim(); + return DNSRuleActions.includes(action) ? action : 'direct'; +} + +function parseLegacyDNSBlockTypes(blockTypes) { + if (blockTypes === null || blockTypes === undefined || blockTypes === '') { + return []; + } + + if (Array.isArray(blockTypes)) { + return blockTypes + .map(item => Number(item)) + .filter(item => Number.isInteger(item) && item >= 0 && item <= 65535); + } + + if (typeof blockTypes === 'number') { + return Number.isInteger(blockTypes) && blockTypes >= 0 && blockTypes <= 65535 ? [blockTypes] : []; + } + + return blockTypes + .toString() + .split(',') + .map(item => item.trim()) + .filter(item => /^\d+$/.test(item)) + .map(item => Number(item)) + .filter(item => item >= 0 && item <= 65535); +} + +function buildLegacyDNSRules(nonIPQuery, blockTypes) { + const mode = ['reject', 'drop', 'skip'].includes(nonIPQuery) ? nonIPQuery : 'reject'; + const rules = []; + const parsedBlockTypes = parseLegacyDNSBlockTypes(blockTypes); + + if (parsedBlockTypes.length > 0) { + rules.push(new Outbound.DNSRule(mode === 'reject' ? 'reject' : 'drop', parsedBlockTypes.join(','))); + } + + rules.push(new Outbound.DNSRule('hijack', '1,28')); + rules.push(new Outbound.DNSRule(mode === 'skip' ? 'direct' : mode)); + + return rules; +} + +function getDNSRulesFromJson(json = {}) { + if (Array.isArray(json.rules) && json.rules.length > 0) { + return json.rules.map(rule => Outbound.DNSRule.fromJson(rule)); + } + + if (json.nonIPQuery !== undefined || json.blockTypes !== undefined) { + return buildLegacyDNSRules(json.nonIPQuery, json.blockTypes); + } + + return []; +} + Object.freeze(Protocols); Object.freeze(SSMethods); Object.freeze(TLS_FLOW_CONTROL); @@ -107,6 +175,7 @@ Object.freeze(WireguardDomainStrategy); Object.freeze(USERS_SECURITY); Object.freeze(MODE_OPTION); Object.freeze(Address_Port_Strategy); +Object.freeze(DNSRuleActions); class CommonClass { @@ -1277,20 +1346,69 @@ Outbound.BlackholeSettings = class extends CommonClass { }; } }; + +Outbound.DNSRule = class extends CommonClass { + constructor(action = 'direct', qtype = '', domain = '') { + super(); + this.action = action; + this.qtype = qtype; + this.domain = domain; + } + + static fromJson(json = {}) { + return new Outbound.DNSRule( + json.action, + normalizeDNSRuleField(json.qtype), + normalizeDNSRuleField(json.domain), + ); + } + + toJson() { + const rule = { + action: normalizeDNSRuleAction(this.action), + }; + + const qtype = normalizeDNSRuleField(this.qtype); + if (!ObjectUtil.isEmpty(qtype)) { + if (/^\d+$/.test(qtype)) { + rule.qtype = Number(qtype); + } else { + rule.qtype = qtype; + } + } + + const domains = normalizeDNSRuleField(this.domain) + .split(',') + .map(d => d.trim()) + .filter(d => d.length > 0); + if (domains.length > 0) { + rule.domain = domains; + } + + return rule; + } +}; + Outbound.DNSSettings = class extends CommonClass { constructor( network = 'udp', address = '', port = 53, - nonIPQuery = 'reject', - blockTypes = [] + rules = [] ) { super(); this.network = network; this.address = address; this.port = port; - this.nonIPQuery = nonIPQuery; - this.blockTypes = blockTypes; + this.rules = Array.isArray(rules) ? rules.map(rule => rule instanceof Outbound.DNSRule ? rule : Outbound.DNSRule.fromJson(rule)) : []; + } + + addRule(action = 'direct') { + this.rules.push(new Outbound.DNSRule(action)); + } + + delRule(index) { + this.rules.splice(index, 1); } static fromJson(json = {}) { @@ -1298,10 +1416,23 @@ Outbound.DNSSettings = class extends CommonClass { json.network, json.address, json.port, - json.nonIPQuery, - json.blockTypes, + getDNSRulesFromJson(json), ); } + + toJson() { + const json = { + network: this.network, + address: this.address, + port: this.port, + }; + + if (this.rules.length > 0) { + json.rules = Outbound.DNSRule.toJsonArray(this.rules); + } + + return json; + } }; Outbound.VmessSettings = class extends CommonClass { constructor(address, port, id, security) { diff --git a/web/html/form/outbound.html b/web/html/form/outbound.html index a9119cf0..c350d70f 100644 --- a/web/html/form/outbound.html +++ b/web/html/form/outbound.html @@ -190,22 +190,73 @@ > - - - [[ s ]] - + + - - - + + Rule [[ index + 1 ]] + + + + + + [[ action ]] + + + + + + + + + + + + + diff --git a/web/service/inbound.go b/web/service/inbound.go index 7d5d8932..8ab5e6a8 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -779,7 +779,7 @@ func (s *InboundService) writeBackClientSubID(sourceInboundID int, sourceProtoco } settingsBytes, err := json.Marshal(map[string][]model.Client{ - "clients": []model.Client{client}, + "clients": {client}, }) if err != nil { return false, err diff --git a/web/web.go b/web/web.go index 835e82e1..f25dada2 100644 --- a/web/web.go +++ b/web/web.go @@ -353,14 +353,17 @@ func (s *Server) startTask() { isTgbotenabled, err := s.settingService.GetTgbotEnabled() if (err == nil) && (isTgbotenabled) { runtime, err := s.settingService.GetTgbotRuntime() - if err != nil || runtime == "" { - logger.Errorf("Add NewStatsNotifyJob error[%s], Runtime[%s] invalid, will run default", err, runtime) + if err != nil { + logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err) + runtime = "@daily" + } else if strings.TrimSpace(runtime) == "" { + logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily") runtime = "@daily" } logger.Infof("Tg notify enabled,run at %s", runtime) _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()) if err != nil { - logger.Warning("Add NewStatsNotifyJob error", err) + logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err) return }