MHSanaei 2 днів тому
батько
коміт
a62c637632
4 змінених файлів з 209 додано та 24 видалено
  1. 137 6
      web/assets/js/model/outbound.js
  2. 65 14
      web/html/form/outbound.html
  3. 1 1
      web/service/inbound.go
  4. 6 3
      web/web.go

+ 137 - 6
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) {

+ 65 - 14
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 -->

+ 1 - 1
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

+ 6 - 3
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
 		}