1
0

6 کامیت‌ها 35609b7b13 ... abc5cf3439

نویسنده SHA1 پیام تاریخ
  MHSanaei abc5cf3439 Increase KCP maxSendingWindow to 2MiB 4 روز پیش
  MHSanaei a7e7788e29 Bump Xray release to v26.4.25 4 روز پیش
  MHSanaei 8620344925 Replace with-block with explicit settings 4 روز پیش
  MHSanaei 47e229e323 Default to dark theme when unset 4 روز پیش
  MHSanaei 4521beab7c wireguard: link 4 روز پیش
  MHSanaei a62c637632 DNS outbound: Add rules 4 روز پیش

+ 2 - 2
.github/workflows/release.yml

@@ -126,7 +126,7 @@ jobs:
           cd x-ui/bin
 
           # Download dependencies
-          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
+          Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
           if [ "${{ matrix.platform }}" == "amd64" ]; then
             wget -q ${Xray_URL}Xray-linux-64.zip
             unzip Xray-linux-64.zip
@@ -244,7 +244,7 @@ jobs:
           cd x-ui\bin
 
           # Download Xray for Windows
-          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
+          $Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
           Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
           Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
           Remove-Item "Xray-windows-64.zip"

+ 1 - 1
DockerInit.sh

@@ -27,7 +27,7 @@ case $1 in
 esac
 mkdir -p build/bin
 cd build/bin
-curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"

+ 2 - 2
go.mod

@@ -72,7 +72,7 @@ require (
 	github.com/quic-go/quic-go v0.59.0 // indirect
 	github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
 	github.com/rogpeppe/go-internal v1.14.1 // indirect
-	github.com/sagernet/sing v0.8.8 // indirect
+	github.com/sagernet/sing v0.8.9 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
 	github.com/tklauser/go-sysconf v0.3.16 // indirect
 	github.com/tklauser/numcpus v0.11.0 // indirect
@@ -95,7 +95,7 @@ require (
 	golang.org/x/tools v0.44.0 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect
 	google.golang.org/protobuf v1.36.11 // indirect
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect

+ 4 - 4
go.sum

@@ -156,8 +156,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
-github.com/sagernet/sing v0.8.8 h1:1dRlGJ3wm4d2nwjKI1R/dr/7GKDKgUvXyD4OAWlQyt8=
-github.com/sagernet/sing v0.8.8/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
+github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
+github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
 github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
 github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
 github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
@@ -256,8 +256,8 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z
 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
 google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

+ 45 - 7
web/assets/js/model/inbound.js

@@ -323,7 +323,7 @@ class KcpStreamSettings extends XrayCommonClass {
         uplinkCapacity = 5,
         downlinkCapacity = 20,
         cwndMultiplier = 1,
-        maxSendingWindow = 1350,
+        maxSendingWindow = 2097152,
     ) {
         super();
         this.mtu = mtu;
@@ -1907,7 +1907,7 @@ class Inbound extends XrayCommonClass {
         return url.toString();
     }
 
-    getWireguardLink(address, port, remark, peerId) {
+    getWireguardTxt(address, port, remark, peerId) {
         let txt = `[Interface]\n`
         txt += `PrivateKey = ${this.settings.peers[peerId].privateKey}\n`
         txt += `Address = ${this.settings.peers[peerId].allowedIPs[0]}\n`
@@ -1929,6 +1929,48 @@ class Inbound extends XrayCommonClass {
         return txt;
     }
 
+    getWireguardLink(address, port, remark, peerId) {
+        const peer = this.settings?.peers?.[peerId];
+        if (!peer) return '';
+
+        const link = `wireguard://${address}:${port}`;
+        const url = new URL(link);
+        url.username = peer.privateKey || '';
+
+        if (this.settings?.pubKey) {
+            url.searchParams.set("publickey", this.settings.pubKey);
+        }
+        if (Array.isArray(peer.allowedIPs) && peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
+            url.searchParams.set("address", peer.allowedIPs[0]);
+        }
+        if (this.settings?.mtu) {
+            url.searchParams.set("mtu", this.settings.mtu);
+        }
+
+        url.hash = encodeURIComponent(remark);
+        return url.toString();
+    }
+
+    genWireguardLinks(remark = '', remarkModel = '-ieo') {
+        const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
+        const separationChar = remarkModel.charAt(0);
+        let links = [];
+        this.settings.peers.forEach((p, index) => {
+            links.push(this.getWireguardLink(addr, this.port, remark + separationChar + (index + 1), index));
+        });
+        return links.join('\r\n');
+    }
+
+    genWireguardConfigs(remark = '', remarkModel = '-ieo') {
+        const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
+        const separationChar = remarkModel.charAt(0);
+        let links = [];
+        this.settings.peers.forEach((p, index) => {
+            links.push(this.getWireguardTxt(addr, this.port, remark + separationChar + (index + 1), index));
+        });
+        return links.join('\r\n');
+    }
+
     genLink(address = '', port = this.port, forceTls = 'same', remark = '', client) {
         switch (this.protocol) {
             case Protocols.VMESS:
@@ -1989,11 +2031,7 @@ class Inbound extends XrayCommonClass {
         } else {
             if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark);
             if (this.protocol == Protocols.WIREGUARD) {
-                let links = [];
-                this.settings.peers.forEach((p, index) => {
-                    links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index));
-                });
-                return links.join('\r\n');
+                return this.genWireguardConfigs(remark, remarkModel);
             }
             return '';
         }

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

+ 2 - 1
web/html/component/aThemeSwitch.html

@@ -40,7 +40,8 @@
 {{define "component/aThemeSwitch"}}
 <script>
   function createThemeSwitcher() {
-    const isDarkTheme = localStorage.getItem('dark-mode') === 'true';
+    const darkModePreference = localStorage.getItem('dark-mode');
+    const isDarkTheme = darkModePreference === null ? true : darkModePreference === 'true';
     const isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true';
     if (isUltra) {
       document.documentElement.setAttribute('data-theme', 'ultra-dark');

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

+ 17 - 18
web/html/inbounds.html

@@ -1164,24 +1164,23 @@
         if (!msg.success) {
           return;
         }
-        with (msg.obj) {
-          this.expireDiff = expireDiff * 86400000;
-          this.trafficDiff = trafficDiff * 1073741824;
-          this.defaultCert = defaultCert;
-          this.defaultKey = defaultKey;
-          this.tgBotEnable = tgBotEnable;
-          this.subSettings = {
-            enable: subEnable,
-            subTitle: subTitle,
-            subURI: subURI,
-            subJsonURI: subJsonURI,
-            subJsonEnable: subJsonEnable,
-          };
-          this.pageSize = pageSize;
-          this.remarkModel = remarkModel;
-          this.datepicker = datepicker;
-          this.ipLimitEnable = ipLimitEnable;
-        }
+        const settings = msg.obj || {};
+        this.expireDiff = settings.expireDiff * 86400000;
+        this.trafficDiff = settings.trafficDiff * 1073741824;
+        this.defaultCert = settings.defaultCert;
+        this.defaultKey = settings.defaultKey;
+        this.tgBotEnable = settings.tgBotEnable;
+        this.subSettings = {
+          enable: settings.subEnable,
+          subTitle: settings.subTitle,
+          subURI: settings.subURI,
+          subJsonURI: settings.subJsonURI,
+          subJsonEnable: settings.subJsonEnable,
+        };
+        this.pageSize = settings.pageSize;
+        this.remarkModel = settings.remarkModel;
+        this.datepicker = settings.datepicker;
+        this.ipLimitEnable = settings.ipLimitEnable;
       },
       setInbounds(dbInbounds) {
         this.inbounds.splice(0);

+ 14 - 1
web/html/modals/inbound_info_modal.html

@@ -513,9 +513,19 @@
                 <div v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)"
                   :style="{ borderRadius: '1rem', padding: '0.5rem' }" class="client-table-odd-row">
                 </div>
+                <a-divider orientation="center">Link</a-divider>
+                <tr-info-title class="tr-info-title">
+                  <a-tag color="green">Link</a-tag>
+                  <a-tooltip title='{{ i18n "copy" }}'>
+                    <a-button :style="{ minWidth: '24px' }" size="small" icon="snippets"
+                      @click="copy(infoModal.wireguardLinks[index])"></a-button>
+                  </a-tooltip>
+                </tr-info-title>
+                <code :style="{ display: 'block', whiteSpace: 'normal', wordBreak: 'break-all' }">[[ infoModal.wireguardLinks[index] ]]</code>
               </tr-info-row>
             </td>
           </tr>
+        </template>
       </table>
     </template>
     </template>
@@ -603,6 +613,7 @@
     upStats: 0,
     downStats: 0,
     links: [],
+    wireguardLinks: [],
     index: null,
     isExpired: false,
     subLink: '',
@@ -633,9 +644,11 @@
         }
       }
       if (this.inbound.protocol == Protocols.WIREGUARD) {
-        this.links = this.inbound.genInboundLinks(dbInbound.remark).split('\r\n')
+        this.links = this.inbound.genWireguardConfigs(dbInbound.remark).split('\r\n')
+        this.wireguardLinks = this.inbound.genWireguardLinks(dbInbound.remark).split('\r\n')
       } else {
         this.links = this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, this.clientSettings);
+        this.wireguardLinks = [];
       }
       if (this.clientSettings) {
         if (this.clientSettings.subId) {

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