Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/MHSanaei/3x-ui.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMHSanaei <ho3ein.sanaei@gmail.com>2026-04-26 18:34:31 +0300
committerMHSanaei <ho3ein.sanaei@gmail.com>2026-04-26 18:34:31 +0300
commita62c637632d0e4f0ff74fe185a3ea50048cb73d8 (patch)
treeb0f4446e30d1ef9c5fd81ba1a4d293954d152705
parent35609b7b132158fc496b5c834766100e86f7c3bf (diff)
DNS outbound: Add rules
-rw-r--r--web/assets/js/model/outbound.js143
-rw-r--r--web/html/form/outbound.html79
-rw-r--r--web/service/inbound.go2
-rw-r--r--web/web.go9
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 @@
>
</a-select>
</a-form-item>
- <a-form-item label="non-IP queries">
- <a-select
- v-model="outbound.settings.nonIPQuery"
- :dropdown-class-name="themeSwitcher.currentTheme"
- >
- <a-select-option v-for="s in ['reject','drop','skip']" :value="s"
- >[[ s ]]</a-select-option
- >
- </a-select>
+ <a-form-item label="Rules">
+ <a-button
+ icon="plus"
+ type="primary"
+ size="small"
+ @click="outbound.settings.addRule()"
+ ></a-button>
</a-form-item>
- <a-form-item
- v-if="outbound.settings.nonIPQuery === 'skip'"
- label="Block Types"
+
+ <a-form
+ v-for="(rule, index) in outbound.settings.rules"
+ :colon="false"
+ :label-col="{ md: {span:8} }"
+ :wrapper-col="{ md: {span:14} }"
>
- <a-input v-model.number="outbound.settings.blockTypes"></a-input>
- </a-form-item>
+ <a-divider :style="{ margin: '0' }">
+ Rule [[ index + 1 ]]
+ <a-icon
+ type="delete"
+ @click="() => outbound.settings.delRule(index)"
+ :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
+ ></a-icon>
+ </a-divider>
+
+ <a-form-item label="Action">
+ <a-select
+ v-model="rule.action"
+ :dropdown-class-name="themeSwitcher.currentTheme"
+ >
+ <a-select-option v-for="action in DNSRuleActions" :value="action"
+ >[[ action ]]</a-select-option
+ >
+ </a-select>
+ </a-form-item>
+
+ <a-form-item>
+ <template slot="label">
+ <a-tooltip>
+ <template slot="title">
+ <span>Single qtype (e.g. 28) or list/range (e.g. 1,3,23-24)</span>
+ </template>
+ QType
+ <a-icon type="question-circle"></a-icon>
+ </a-tooltip>
+ </template>
+ <a-input
+ v-model.trim="rule.qtype"
+ placeholder="1,3,23-24"
+ ></a-input>
+ </a-form-item>
+
+ <a-form-item>
+ <template slot="label">
+ <a-tooltip>
+ <template slot="title">
+ <span>Comma-separated domain rules, e.g. domain:example.com,full:example.com</span>
+ </template>
+ Domain
+ <a-icon type="question-circle"></a-icon>
+ </a-tooltip>
+ </template>
+ <a-input
+ v-model.trim="rule.domain"
+ placeholder="domain:example.com,full:example.com"
+ ></a-input>
+ </a-form-item>
+ </a-form>
</template>
<!-- wireguard settings -->
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
}