3 Commits ad30298700 ... 3b64a62137

Autor SHA1 Mensagem Data
  MHSanaei 3b64a62137 refactor(vless): drop selectedAuth, expose two explicit auth buttons 3 dias atrás
  MHSanaei 79a7e7a5b5 fix(vless): scope testseed to xtls-rprx-vision flow 3 dias atrás
  MHSanaei 3349dcbc13 fix(fail2ban): fix banning regression and Docker zero-jail issue 3 dias atrás

+ 1 - 0
.gitignore

@@ -1,6 +1,7 @@
 # Ignore editor and IDE settings
 .idea/
 .vscode/
+.claude/
 .cache/
 .sync*
 

+ 56 - 2
DockerEntrypoint.sh

@@ -1,7 +1,61 @@
 #!/bin/sh
 
-# Start fail2ban
-[ $XUI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start
+# Start fail2ban with the 3x-ipl jail
+if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then
+    LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}"
+    mkdir -p "$LOG_FOLDER"
+    touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log"
+
+    mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d
+
+    cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF
+[3x-ipl]
+enabled=true
+backend=auto
+filter=3x-ipl
+action=3x-ipl
+logpath=$LOG_FOLDER/3xipl.log
+maxretry=1
+findtime=32
+bantime=30m
+EOF
+
+    cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF'
+[Definition]
+datepattern = ^%Y/%m/%d %H:%M:%S
+failregex   = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
+ignoreregex =
+EOF
+
+    cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF
+[INCLUDES]
+before = iptables-allports.conf
+
+[Definition]
+actionstart = <iptables> -N f2b-<name>
+              <iptables> -A f2b-<name> -j <returntype>
+              <iptables> -I <chain> -p <protocol> -j f2b-<name>
+
+actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
+             <actionflush>
+             <iptables> -X f2b-<name>
+
+actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
+
+actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
+            echo "\$(date +"%Y/%m/%d %H:%M:%S")   BAN   [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> $LOG_FOLDER/3xipl-banned.log
+
+actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
+              echo "\$(date +"%Y/%m/%d %H:%M:%S")   UNBAN   [Email] = <F-USER> [IP] = <ip> unbanned." >> $LOG_FOLDER/3xipl-banned.log
+
+[Init]
+name = default
+protocol = tcp
+chain = INPUT
+EOF
+
+    fail2ban-client -x start
+fi
 
 # Run x-ui
 exec /app/x-ui

+ 29 - 18
web/assets/js/model/inbound.js

@@ -1763,12 +1763,13 @@ class Inbound extends XrayCommonClass {
         return false;
     }
 
-    // Vision seed applies only when vision flow is selected
+    // Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected.
+    // Excludes the UDP variant per spec.
     canEnableVisionSeed() {
         if (!this.canEnableTlsFlow()) return false;
         const clients = this.settings?.vlesses;
         if (!Array.isArray(clients)) return false;
-        return clients.some(c => c?.flow === TLS_FLOW_CONTROL.VISION || c?.flow === TLS_FLOW_CONTROL.VISION_UDP443);
+        return clients.some(c => c?.flow === TLS_FLOW_CONTROL.VISION);
     }
 
     canEnableReality() {
@@ -2542,15 +2543,13 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
         decryption = "none",
         encryption = "none",
         fallbacks = [],
-        selectedAuth = undefined,
-        testseed = [900, 500, 900, 256],
+        testseed = [],
     ) {
         super(protocol);
         this.vlesses = vlesses;
         this.decryption = decryption;
         this.encryption = encryption;
         this.fallbacks = fallbacks;
-        this.selectedAuth = selectedAuth;
         this.testseed = testseed;
     }
 
@@ -2562,12 +2561,23 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
         this.fallbacks.splice(index, 1);
     }
 
+    // Empty array means "use server defaults" (won't be sent).
+    // Anything else must be exactly 4 positive integers.
+    static isValidTestseed(arr) {
+        if (!Array.isArray(arr) || arr.length === 0) return true;
+        if (arr.length !== 4) return false;
+        return arr.every(v => Number.isInteger(v) && v > 0);
+    }
+
     static fromJson(json = {}) {
-        // Ensure testseed is always initialized as an array
-        let testseed = [900, 500, 900, 256];
-        if (json.testseed && Array.isArray(json.testseed) && json.testseed.length >= 4) {
-            testseed = json.testseed;
-        }
+        // Preserve a saved testseed only if it's a valid 4-positive-int array; otherwise leave empty
+        // so toJson omits it and the form falls back to placeholder defaults.
+        const saved = json.testseed;
+        const testseed = (Array.isArray(saved)
+            && saved.length === 4
+            && saved.every(v => Number.isInteger(v) && v > 0))
+            ? saved
+            : [];
 
         const obj = new Inbound.VLESSSettings(
             Protocols.VLESS,
@@ -2575,8 +2585,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
             json.decryption,
             json.encryption,
             Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
-            json.selectedAuth,
-            testseed
+            testseed,
         );
         return obj;
     }
@@ -2598,13 +2607,15 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
         if (this.fallbacks && this.fallbacks.length > 0) {
             json.fallbacks = Inbound.VLESSSettings.toJsonArray(this.fallbacks);
         }
-        if (this.selectedAuth) {
-            json.selectedAuth = this.selectedAuth;
-        }
 
-        // Only include testseed if at least one client has a flow set
-        const hasFlow = this.vlesses && this.vlesses.some(vless => vless.flow && vless.flow !== '');
-        if (hasFlow && this.testseed && this.testseed.length >= 4) {
+        // testseed is only meaningful for the exact xtls-rprx-vision flow, and only when
+        // the user supplied a complete 4-positive-int array. Otherwise omit and let the
+        // backend fall back to its safe defaults.
+        const hasVisionFlow = this.vlesses && this.vlesses.some(v => v.flow === TLS_FLOW_CONTROL.VISION);
+        if (hasVisionFlow
+            && Array.isArray(this.testseed)
+            && this.testseed.length === 4
+            && this.testseed.every(v => Number.isInteger(v) && v > 0)) {
             json.testseed = this.testseed;
         }
 

+ 16 - 8
web/assets/js/model/outbound.js

@@ -1139,11 +1139,11 @@ class Outbound extends CommonClass {
         return false;
     }
 
-    // Vision seed applies only when vision flow is selected
+    // Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected.
+    // Excludes the UDP variant per spec.
     canEnableVisionSeed() {
         if (!this.canEnableTlsFlow()) return false;
-        const flow = this.settings?.flow;
-        return flow === TLS_FLOW_CONTROL.VISION || flow === TLS_FLOW_CONTROL.VISION_UDP443;
+        return this.settings?.flow === TLS_FLOW_CONTROL.VISION;
     }
 
     canEnableReality() {
@@ -1799,7 +1799,7 @@ Outbound.VmessSettings = class extends CommonClass {
     }
 };
 Outbound.VLESSSettings = class extends CommonClass {
-    constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = [900, 500, 900, 256]) {
+    constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) {
         super();
         this.address = address;
         this.port = port;
@@ -1814,6 +1814,12 @@ Outbound.VLESSSettings = class extends CommonClass {
 
     static fromJson(json = {}) {
         if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
+        const saved = json.testseed;
+        const testseed = (Array.isArray(saved)
+            && saved.length === 4
+            && saved.every(v => Number.isInteger(v) && v > 0))
+            ? saved
+            : [];
         return new Outbound.VLESSSettings(
             json.address,
             json.port,
@@ -1823,7 +1829,7 @@ Outbound.VLESSSettings = class extends CommonClass {
             json.reverse?.tag || '',
             ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
             json.testpre || 0,
-            json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
+            testseed,
         );
     }
 
@@ -1843,12 +1849,14 @@ Outbound.VLESSSettings = class extends CommonClass {
                 sniffing: JSON.stringify(reverseSniffing) === JSON.stringify(defaultReverseSniffing) ? {} : reverseSniffing,
             };
         }
-        // Only include Vision settings when flow is set
-        if (this.flow && this.flow !== '') {
+        // Vision-specific knobs are only meaningful for the exact xtls-rprx-vision flow.
+        if (this.flow === TLS_FLOW_CONTROL.VISION) {
             if (this.testpre > 0) {
                 result.testpre = this.testpre;
             }
-            if (this.testseed && this.testseed.length >= 4) {
+            if (Array.isArray(this.testseed)
+                && this.testseed.length === 4
+                && this.testseed.every(v => Number.isInteger(v) && v > 0)) {
                 result.testseed = this.testseed;
             }
         }

+ 29 - 23
web/html/form/protocol/vless.html

@@ -21,16 +21,6 @@
 </a-collapse>
 <template v-if=" !inbound.stream.isTLS || !inbound.stream.isReality">
   <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
-    <a-form-item label="Authentication">
-      <a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
-        :dropdown-class-name="themeSwitcher.currentTheme">
-        <a-select-option :value="undefined">None</a-select-option>
-        <a-select-option value="X25519, not Post-Quantum">X25519 (not
-          Post-Quantum)</a-select-option>
-        <a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
-          (Post-Quantum)</a-select-option>
-      </a-select>
-    </a-form-item>
     <a-form-item label="decryption">
       <a-input v-model.trim="inbound.settings.decryption"></a-input>
     </a-form-item>
@@ -38,16 +28,20 @@
       <a-input v-model="inbound.settings.encryption"></a-input>
     </a-form-item>
     <a-form-item label=" ">
-      <a-space>
-        <a-button type="primary" icon="import" @click="getNewVlessEnc">Get New
-          keys</a-button>
+      <a-space :size="8" wrap>
+        <a-button type="primary" icon="import" @click="getNewVlessEnc('X25519, not Post-Quantum')">
+          X25519
+        </a-button>
+        <a-button type="primary" icon="import" @click="getNewVlessEnc('ML-KEM-768, Post-Quantum')">
+          ML-KEM-768
+        </a-button>
         <a-button danger @click="clearVlessEnc">Clear</a-button>
       </a-space>
     </a-form-item>
   </a-form>
   <a-divider :style="{ margin: '5px 0' }"></a-divider>
 </template>
-<template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
+<template v-if="inbound.isTcp && (!inbound.settings.encryption || inbound.settings.encryption === 'none')">
   <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
     <a-form-item label="Fallbacks">
       <a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
@@ -81,30 +75,38 @@
 </template>
 <template v-if="inbound.canEnableVisionSeed()">
   <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
-    <a-form-item label="Vision Seed">
+    <a-form-item
+      :validate-status="testseedError() ? 'error' : ''"
+      :help="testseedError() || ''">
+      <template slot="label">
+        Vision Seed
+        <a-tooltip title="Optional. Controls XTLS Vision padding. Provide exactly 4 positive integers, or leave empty to use defaults: [900, 500, 900, 256].">
+          <a-icon type="question-circle" :style="{ marginLeft: '4px' }"></a-icon>
+        </a-tooltip>
+      </template>
       <a-row :gutter="8">
         <a-col :span="6">
           <a-input-number
-            :value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900"
-            @change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
+            :value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : null"
+            @change="(val) => updateTestseed(0, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="900"
             addon-before="[0]"></a-input-number>
         </a-col>
         <a-col :span="6">
           <a-input-number
-            :value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500"
-            @change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500"
+            :value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : null"
+            @change="(val) => updateTestseed(1, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="500"
             addon-before="[1]"></a-input-number>
         </a-col>
         <a-col :span="6">
           <a-input-number
-            :value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900"
-            @change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
+            :value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : null"
+            @change="(val) => updateTestseed(2, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="900"
             addon-before="[2]"></a-input-number>
         </a-col>
         <a-col :span="6">
           <a-input-number
-            :value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256"
-            @change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256"
+            :value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : null"
+            @change="(val) => updateTestseed(3, val)" :min="1" :max="9999" :style="{ width: '100%' }" placeholder="256"
             addon-before="[3]"></a-input-number>
         </a-col>
       </a-row>
@@ -116,6 +118,10 @@
           Reset
         </a-button>
       </a-space>
+      <div :style="{ marginTop: '6px', fontSize: '12px', color: 'inherit', opacity: 0.65, lineHeight: 1.4 }">
+        Optional. Controls XTLS Vision padding behavior (used only for xtls-rprx-vision).
+        Provide exactly four positive integers to customize padding; otherwise leave empty to use safe defaults.
+      </div>
     </a-form-item>
   </a-form>
   <a-divider :style="{ margin: '5px 0' }"></a-divider>

+ 2 - 7
web/html/modals/inbound_info_modal.html

@@ -90,14 +90,9 @@
       <a-tag :color="inbound.stream.security == 'none' ? 'red' : 'green'">[[
         inbound.stream.security ]]</a-tag>
       <br />
-      <td>Authentication</td>
-      <a-tag v-if="inbound.settings.selectedAuth" color="green">[[
-        inbound.settings.selectedAuth ? inbound.settings.selectedAuth : ''
-        ]]</a-tag>
-      <a-tag v-else color="red">{{ i18n "none" }}</a-tag>
-      <br />
       {{ i18n "encryption" }}
-      <a-tag class="info-large-tag" :color="inbound.settings.encryption ? 'green' : 'red'">[[
+      <a-tag class="info-large-tag"
+        :color="inbound.settings.encryption && inbound.settings.encryption !== 'none' ? 'green' : 'red'">[[
         inbound.settings.encryption ? inbound.settings.encryption : ''
         ]]</a-tag>
       <a-tooltip title='{{ i18n "copy" }}'>

+ 79 - 82
web/html/modals/inbound_modal.html

@@ -16,6 +16,16 @@
         inbound: new Inbound(),
         dbInbound: new DBInbound(),
         ok() {
+            // Block submit when Vision Seed is XRV-gated and partially/invalidly filled.
+            const seedErr = inModal.testseedError();
+            if (seedErr) {
+                if (typeof Vue !== "undefined" && Vue.prototype && Vue.prototype.$message) {
+                    Vue.prototype.$message.error(seedErr);
+                } else {
+                    alert(seedErr);
+                }
+                return;
+            }
             ObjectUtil.execute(inModal.confirm, inModal.inbound, inModal.dbInbound);
         },
         show({
@@ -33,16 +43,12 @@
             } else {
                 this.inbound = new Inbound();
             }
-            // Always ensure testseed is initialized for VLESS protocol (even if vision flow is not set yet)
-            // This ensures Vue reactivity works properly
+            // Ensure VLESS settings has a testseed array reference for Vue reactivity,
+            // but leave it empty so we don't auto-emit defaults — user must explicitly
+            // fill all four fields, or leave blank to fall back to backend defaults.
             if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
-                if (
-                    !this.inbound.settings.testseed ||
-                    !Array.isArray(this.inbound.settings.testseed) ||
-                    this.inbound.settings.testseed.length < 4
-                ) {
-                    // Create a new array to ensure Vue reactivity
-                    this.inbound.settings.testseed = [900, 500, 900, 256].slice();
+                if (!Array.isArray(this.inbound.settings.testseed)) {
+                    this.inbound.settings.testseed = [];
                 }
             }
             if (dbInbound) {
@@ -61,48 +67,50 @@
         loading(loading = true) {
             inModal.confirmLoading = loading;
         },
-        // Vision Seed methods - always available regardless of Vue context
+        // Returns an error string when the current testseed state would be rejected,
+        // or "" when it's valid (empty == use defaults; full 4 positive ints == custom).
+        testseedError() {
+            if (!inModal.inbound || inModal.inbound.protocol !== Protocols.VLESS) return "";
+            if (typeof inModal.inbound.canEnableVisionSeed === "function"
+                && !inModal.inbound.canEnableVisionSeed()) return "";
+            const seed = inModal.inbound.settings && inModal.inbound.settings.testseed;
+            if (!Array.isArray(seed) || seed.length === 0) return "";
+            const filled = seed.filter(v => v !== null && v !== undefined && v !== "");
+            if (filled.length === 0) return "";
+            if (seed.length !== 4 || filled.length !== 4 ||
+                !seed.every(v => Number.isInteger(v) && v > 0)) {
+                return "Provide exactly 4 positive integers or leave empty to use defaults.";
+            }
+            return "";
+        },
+        // Vision Seed helpers — always available regardless of Vue context
         updateTestseed(index, value) {
-            // Use inModal.inbound explicitly to ensure correct context
             if (!inModal.inbound || !inModal.inbound.settings) return;
-            // Ensure testseed is initialized
-            if (
-                !inModal.inbound.settings.testseed ||
-                !Array.isArray(inModal.inbound.settings.testseed)
-            ) {
-                inModal.inbound.settings.testseed = [900, 500, 900, 256];
+            if (!Array.isArray(inModal.inbound.settings.testseed)) {
+                inModal.inbound.settings.testseed = [];
             }
-            // Ensure array has enough elements
-            while (inModal.inbound.settings.testseed.length <= index) {
-                inModal.inbound.settings.testseed.push(0);
+            const seed = inModal.inbound.settings.testseed;
+            while (seed.length <= index) seed.push(null);
+            seed[index] = value;
+            // If user cleared every slot, collapse back to empty so we omit testseed entirely.
+            if (seed.every(v => v === null || v === undefined || v === "")) {
+                inModal.inbound.settings.testseed = [];
             }
-            // Update value
-            inModal.inbound.settings.testseed[index] = value;
         },
         setRandomTestseed() {
-            // Use inModal.inbound explicitly to ensure correct context
             if (!inModal.inbound || !inModal.inbound.settings) return;
-            // Ensure testseed is initialized
-            if (
-                !inModal.inbound.settings.testseed ||
-                !Array.isArray(inModal.inbound.settings.testseed) ||
-                inModal.inbound.settings.testseed.length < 4
-            ) {
-                inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
-            }
-            // Create new array with random values
+            // Positive integers only (>=1) so the array passes validation and gets emitted.
             inModal.inbound.settings.testseed = [
-                Math.floor(Math.random() * 1000),
-                Math.floor(Math.random() * 1000),
-                Math.floor(Math.random() * 1000),
-                Math.floor(Math.random() * 1000),
+                Math.floor(Math.random() * 999) + 1,
+                Math.floor(Math.random() * 999) + 1,
+                Math.floor(Math.random() * 999) + 1,
+                Math.floor(Math.random() * 999) + 1,
             ];
         },
         resetTestseed() {
-            // Use inModal.inbound explicitly to ensure correct context
             if (!inModal.inbound || !inModal.inbound.settings) return;
-            // Reset testseed to default values
-            inModal.inbound.settings.testseed = [900, 500, 900, 256].slice();
+            // Empty == "use server defaults [900, 500, 900, 256]"; placeholders show in the form.
+            inModal.inbound.settings.testseed = [];
         },
     });
 
@@ -170,27 +178,17 @@
                     });
                 }
             },
-            // Ensure testseed is always initialized when vision flow is enabled
+            // Keep testseed as a valid array reference for Vue reactivity while the user
+            // toggles flows — but do NOT auto-fill defaults. Empty means "use server defaults"
+            // and is the only way the form omits testseed from the outbound JSON.
             "inModal.inbound.settings.vlesses": {
                 handler() {
                     if (
                         inModal.inbound.protocol === Protocols.VLESS &&
                         inModal.inbound.settings &&
-                        inModal.inbound.settings.vlesses
+                        !Array.isArray(inModal.inbound.settings.testseed)
                     ) {
-                        const hasVisionFlow = inModal.inbound.settings.vlesses.some(
-                            (c) =>
-                            c.flow === "xtls-rprx-vision" ||
-                            c.flow === "xtls-rprx-vision-udp443",
-                        );
-                        if (
-                            hasVisionFlow &&
-                            (!inModal.inbound.settings.testseed ||
-                                !Array.isArray(inModal.inbound.settings.testseed) ||
-                                inModal.inbound.settings.testseed.length < 4)
-                        ) {
-                            inModal.inbound.settings.testseed = [900, 500, 900, 256];
-                        }
+                        inModal.inbound.settings.testseed = [];
                     }
                 },
                 deep: true,
@@ -304,12 +302,11 @@
                 this.inbound.stream.tls.echServerKeys = "";
                 this.inbound.stream.tls.settings.echConfigList = "";
             },
-            async getNewVlessEnc() {
-                const selected = inModal.inbound.settings.selectedAuth;
-                if (!selected) {
-                    this.clearVlessEnc();
-                    return;
-                }
+            // Pulls the requested auth block from `xray vlessenc` (which always returns
+            // both X25519 and ML-KEM-768 variants) and applies it to the inbound's
+            // decryption/encryption strings. The auth mode is implied by the resulting
+            async getNewVlessEnc(authLabel) {
+                if (!authLabel) return;
 
                 inModal.loading(true);
                 const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
@@ -320,10 +317,10 @@
                 }
 
                 const auths = msg.obj.auths || [];
-                const block = auths.find((a) => a.label === selected);
+                const block = auths.find((a) => a.label === authLabel);
 
                 if (!block) {
-                    console.error("No auth block for", selected);
+                    console.error("No auth block for", authLabel);
                     return;
                 }
 
@@ -333,37 +330,37 @@
             clearVlessEnc() {
                 this.inbound.settings.decryption = "none";
                 this.inbound.settings.encryption = "none";
-                this.inbound.settings.selectedAuth = undefined;
             },
-            // Vision Seed methods - must be in Vue methods for proper binding
+            // Vision Seed methods - must be in Vue methods for proper template binding.
+            // Mirror the inModal helpers but use Vue.set so the form re-renders.
             updateTestseed(index, value) {
-                // Ensure testseed is initialized
-                if (
-                    !this.inbound.settings.testseed ||
-                    !Array.isArray(this.inbound.settings.testseed)
-                ) {
-                    this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]);
+                if (!Array.isArray(this.inbound.settings.testseed)) {
+                    this.$set(this.inbound.settings, "testseed", []);
                 }
-                // Ensure array has enough elements
-                while (this.inbound.settings.testseed.length <= index) {
-                    this.inbound.settings.testseed.push(0);
+                const seed = this.inbound.settings.testseed;
+                while (seed.length <= index) seed.push(null);
+                this.$set(seed, index, value);
+                // Collapse to empty when every slot is cleared so testseed is omitted from JSON.
+                if (seed.every(v => v === null || v === undefined || v === "")) {
+                    this.$set(this.inbound.settings, "testseed", []);
                 }
-                // Update value using Vue.set for reactivity
-                this.$set(this.inbound.settings.testseed, index, value);
             },
             setRandomTestseed() {
-                // Create new array with random values and use Vue.set for reactivity
+                // Positive integers only (>=1) so the resulting array passes validation.
                 const newSeed = [
-                    Math.floor(Math.random() * 1000),
-                    Math.floor(Math.random() * 1000),
-                    Math.floor(Math.random() * 1000),
-                    Math.floor(Math.random() * 1000),
+                    Math.floor(Math.random() * 999) + 1,
+                    Math.floor(Math.random() * 999) + 1,
+                    Math.floor(Math.random() * 999) + 1,
+                    Math.floor(Math.random() * 999) + 1,
                 ];
                 this.$set(this.inbound.settings, "testseed", newSeed);
             },
             resetTestseed() {
-                // Reset testseed to default values using Vue.set for reactivity
-                this.$set(this.inbound.settings, "testseed", [900, 500, 900, 256]);
+                // Empty == "use server defaults [900, 500, 900, 256]". Placeholders will show in the form.
+                this.$set(this.inbound.settings, "testseed", []);
+            },
+            testseedError() {
+                return inModal.testseedError();
             },
         },
     });

+ 13 - 11
web/job/check_client_ip_job.go

@@ -403,16 +403,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 	shouldCleanLog := false
 	j.disAllowedIps = []string{}
 
-	// Open log file
-	logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
-	if err != nil {
-		logger.Errorf("failed to open IP limit log file: %s", err)
-		return false
-	}
-	defer logIpFile.Close()
-	log.SetOutput(logIpFile)
-	log.SetFlags(log.LstdFlags)
-
 	// historical db-only ips are excluded from this count on purpose.
 	var keptLive []IPWithTimestamp
 	if len(liveIps) > limitIp {
@@ -422,13 +412,25 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
 		keptLive = liveIps[:limitIp]
 		bannedLive := liveIps[limitIp:]
 
+		// Open log file only when a ban entry needs to be written.
+		// Use a local logger to avoid mutating the global log.* state,
+		// which would redirect all standard-library logging to this file
+		// and leave a dangling closed-file handle after the defer fires.
+		logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
+		if err != nil {
+			logger.Errorf("failed to open IP limit log file: %s", err)
+			return false
+		}
+		defer logIpFile.Close()
+		ipLogger := log.New(logIpFile, "", log.LstdFlags)
+
 		// log format is load-bearing: x-ui.sh create_iplimit_jails builds
 		// filter.d/3x-ipl.conf with
 		//   failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
 		// don't change the wording.
 		for _, ipTime := range bannedLive {
 			j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
-			log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
+			ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
 		}
 
 		// force xray to drop existing connections from banned ips

+ 35 - 0
web/service/inbound.go

@@ -1213,6 +1213,28 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
 	settingsClients[clientIndex] = interfaceClients[0]
 	oldSettings["clients"] = settingsClients
 
+	// testseed is only meaningful when at least one VLESS client uses the exact
+	// xtls-rprx-vision flow. The client-edit path only rewrites a single client,
+	// so re-check the flow set here and strip a stale testseed when nothing in the
+	// inbound still warrants it. The full-inbound update path already handles this
+	// on the JS side via VLESSSettings.toJson().
+	if oldInbound.Protocol == model.VLESS {
+		hasVisionFlow := false
+		for _, c := range settingsClients {
+			cm, ok := c.(map[string]any)
+			if !ok {
+				continue
+			}
+			if flow, _ := cm["flow"].(string); flow == "xtls-rprx-vision" {
+				hasVisionFlow = true
+				break
+			}
+		}
+		if !hasVisionFlow {
+			delete(oldSettings, "testseed")
+		}
+	}
+
 	newSettings, err := json.MarshalIndent(oldSettings, "", "  ")
 	if err != nil {
 		return false, err
@@ -2885,6 +2907,7 @@ func (s *InboundService) MigrationRequirements() {
 		if ok {
 			// Fix Client configuration problems
 			var newClients []any
+			hasVisionFlow := false
 			for client_index := range clients {
 				c := clients[client_index].(map[string]any)
 
@@ -2910,6 +2933,9 @@ func (s *InboundService) MigrationRequirements() {
 						c["flow"] = ""
 					}
 				}
+				if flow, _ := c["flow"].(string); flow == "xtls-rprx-vision" {
+					hasVisionFlow = true
+				}
 				// Backfill created_at and updated_at
 				if _, ok := c["created_at"]; !ok {
 					c["created_at"] = time.Now().Unix() * 1000
@@ -2918,6 +2944,15 @@ func (s *InboundService) MigrationRequirements() {
 				newClients = append(newClients, any(c))
 			}
 			settings["clients"] = newClients
+
+			// Drop orphaned testseed: VLESS-only field, only meaningful when at least
+			// one client uses the exact xtls-rprx-vision flow. Older versions saved it
+			// for any non-empty flow (including the UDP variant) or kept it after the
+			// flow was cleared from the client modal — clean those up here.
+			if inbounds[inbound_index].Protocol == model.VLESS && !hasVisionFlow {
+				delete(settings, "testseed")
+			}
+
 			modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
 			if err != nil {
 				return

+ 4 - 4
x-ui.sh

@@ -2034,14 +2034,14 @@ backend=auto
 filter=3x-ipl
 action=3x-ipl
 logpath=${iplimit_log_path}
-maxretry=2
+maxretry=1
 findtime=32
 bantime=${bantime}m
 EOF
 
     cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf
 [Definition]
-datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S
+datepattern = ^%Y/%m/%d %H:%M:%S
 failregex   = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
 ignoreregex =
 EOF
@@ -2062,10 +2062,10 @@ actionstop = <iptables> -D <chain> -p <protocol> -j f2b-<name>
 actioncheck = <iptables> -n -L <chain> | grep -q 'f2b-<name>[ \t]'
 
 actionban = <iptables> -I f2b-<name> 1 -s <ip> -j <blocktype>
-            echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S")   BAN   [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
+            echo "\$(date +"%Y/%m/%d %H:%M:%S")   BAN   [Email] = <F-USER> [IP] = <ip> banned for <bantime> seconds." >> ${iplimit_banned_log_path}
 
 actionunban = <iptables> -D f2b-<name> -s <ip> -j <blocktype>
-              echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S")   UNBAN   [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
+              echo "\$(date +"%Y/%m/%d %H:%M:%S")   UNBAN   [Email] = <F-USER> [IP] = <ip> unbanned." >> ${iplimit_banned_log_path}
 
 [Init]
 name = default