Przeglądaj źródła

fix(vless): scope testseed to xtls-rprx-vision flow

testseed is only meaningful for the exact xtls-rprx-vision flow, but the
panel was emitting it for any non-empty flow (including the UDP variant)
and keeping it on the inbound after the flow was cleared via the client
modal. Tighten the gate end-to-end:

- VLESSSettings.toJson (inbound + outbound) now only emits testseed when
  the flow is exactly xtls-rprx-vision and the array is 4 positive ints;
  default state is empty so unmodified inbounds omit the field entirely.
- canEnableVisionSeed drops the udp443 variant per spec.
- Form adds a tooltip + theme-aware help text and an inline error when
  the user partially fills the four inputs; submit is blocked in that
  state. Reset clears to empty (= use server defaults).
- UpdateInboundClient strips a now-orphaned testseed when the spliced
  client no longer leaves any XRV flow in the inbound.
- MigrationRequirements cleans up legacy rows where testseed lingered
  after flow changes or was saved for non-XRV flows by older versions.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
MHSanaei 2 dni temu
rodzic
commit
79a7e7a5b5

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

@@ -1763,12 +1763,13 @@ class Inbound extends XrayCommonClass {
         return false;
         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() {
     canEnableVisionSeed() {
         if (!this.canEnableTlsFlow()) return false;
         if (!this.canEnableTlsFlow()) return false;
         const clients = this.settings?.vlesses;
         const clients = this.settings?.vlesses;
         if (!Array.isArray(clients)) return false;
         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() {
     canEnableReality() {
@@ -2543,7 +2544,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
         encryption = "none",
         encryption = "none",
         fallbacks = [],
         fallbacks = [],
         selectedAuth = undefined,
         selectedAuth = undefined,
-        testseed = [900, 500, 900, 256],
+        testseed = [],
     ) {
     ) {
         super(protocol);
         super(protocol);
         this.vlesses = vlesses;
         this.vlesses = vlesses;
@@ -2562,12 +2563,23 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
         this.fallbacks.splice(index, 1);
         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 = {}) {
     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(
         const obj = new Inbound.VLESSSettings(
             Protocols.VLESS,
             Protocols.VLESS,
@@ -2576,7 +2588,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
             json.encryption,
             json.encryption,
             Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
             Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []),
             json.selectedAuth,
             json.selectedAuth,
-            testseed
+            testseed,
         );
         );
         return obj;
         return obj;
     }
     }
@@ -2602,9 +2614,14 @@ Inbound.VLESSSettings = class extends Inbound.Settings {
             json.selectedAuth = 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;
             json.testseed = this.testseed;
         }
         }
 
 

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

@@ -1139,11 +1139,11 @@ class Outbound extends CommonClass {
         return false;
         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() {
     canEnableVisionSeed() {
         if (!this.canEnableTlsFlow()) return false;
         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() {
     canEnableReality() {
@@ -1799,7 +1799,7 @@ Outbound.VmessSettings = class extends CommonClass {
     }
     }
 };
 };
 Outbound.VLESSSettings = 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();
         super();
         this.address = address;
         this.address = address;
         this.port = port;
         this.port = port;
@@ -1814,6 +1814,12 @@ Outbound.VLESSSettings = class extends CommonClass {
 
 
     static fromJson(json = {}) {
     static fromJson(json = {}) {
         if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
         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(
         return new Outbound.VLESSSettings(
             json.address,
             json.address,
             json.port,
             json.port,
@@ -1823,7 +1829,7 @@ Outbound.VLESSSettings = class extends CommonClass {
             json.reverse?.tag || '',
             json.reverse?.tag || '',
             ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
             ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
             json.testpre || 0,
             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,
                 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) {
             if (this.testpre > 0) {
                 result.testpre = this.testpre;
                 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;
                 result.testseed = this.testseed;
             }
             }
         }
         }

+ 21 - 9
web/html/form/protocol/vless.html

@@ -81,30 +81,38 @@
 </template>
 </template>
 <template v-if="inbound.canEnableVisionSeed()">
 <template v-if="inbound.canEnableVisionSeed()">
   <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
   <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-row :gutter="8">
         <a-col :span="6">
         <a-col :span="6">
           <a-input-number
           <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>
             addon-before="[0]"></a-input-number>
         </a-col>
         </a-col>
         <a-col :span="6">
         <a-col :span="6">
           <a-input-number
           <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>
             addon-before="[1]"></a-input-number>
         </a-col>
         </a-col>
         <a-col :span="6">
         <a-col :span="6">
           <a-input-number
           <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>
             addon-before="[2]"></a-input-number>
         </a-col>
         </a-col>
         <a-col :span="6">
         <a-col :span="6">
           <a-input-number
           <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>
             addon-before="[3]"></a-input-number>
         </a-col>
         </a-col>
       </a-row>
       </a-row>
@@ -116,6 +124,10 @@
           Reset
           Reset
         </a-button>
         </a-button>
       </a-space>
       </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-item>
   </a-form>
   </a-form>
   <a-divider :style="{ margin: '5px 0' }"></a-divider>
   <a-divider :style="{ margin: '5px 0' }"></a-divider>

+ 72 - 73
web/html/modals/inbound_modal.html

@@ -16,6 +16,16 @@
         inbound: new Inbound(),
         inbound: new Inbound(),
         dbInbound: new DBInbound(),
         dbInbound: new DBInbound(),
         ok() {
         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);
             ObjectUtil.execute(inModal.confirm, inModal.inbound, inModal.dbInbound);
         },
         },
         show({
         show({
@@ -33,16 +43,12 @@
             } else {
             } else {
                 this.inbound = new Inbound();
                 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.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) {
             if (dbInbound) {
@@ -61,48 +67,50 @@
         loading(loading = true) {
         loading(loading = true) {
             inModal.confirmLoading = loading;
             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) {
         updateTestseed(index, value) {
-            // Use inModal.inbound explicitly to ensure correct context
             if (!inModal.inbound || !inModal.inbound.settings) return;
             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() {
         setRandomTestseed() {
-            // Use inModal.inbound explicitly to ensure correct context
             if (!inModal.inbound || !inModal.inbound.settings) return;
             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 = [
             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() {
         resetTestseed() {
-            // Use inModal.inbound explicitly to ensure correct context
             if (!inModal.inbound || !inModal.inbound.settings) return;
             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": {
             "inModal.inbound.settings.vlesses": {
                 handler() {
                 handler() {
                     if (
                     if (
                         inModal.inbound.protocol === Protocols.VLESS &&
                         inModal.inbound.protocol === Protocols.VLESS &&
                         inModal.inbound.settings &&
                         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,
                 deep: true,
@@ -335,35 +333,36 @@
                 this.inbound.settings.encryption = "none";
                 this.inbound.settings.encryption = "none";
                 this.inbound.settings.selectedAuth = undefined;
                 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) {
             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() {
             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 = [
                 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);
                 this.$set(this.inbound.settings, "testseed", newSeed);
             },
             },
             resetTestseed() {
             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();
             },
             },
         },
         },
     });
     });

+ 35 - 0
web/service/inbound.go

@@ -1213,6 +1213,28 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
 	settingsClients[clientIndex] = interfaceClients[0]
 	settingsClients[clientIndex] = interfaceClients[0]
 	oldSettings["clients"] = settingsClients
 	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, "", "  ")
 	newSettings, err := json.MarshalIndent(oldSettings, "", "  ")
 	if err != nil {
 	if err != nil {
 		return false, err
 		return false, err
@@ -2885,6 +2907,7 @@ func (s *InboundService) MigrationRequirements() {
 		if ok {
 		if ok {
 			// Fix Client configuration problems
 			// Fix Client configuration problems
 			var newClients []any
 			var newClients []any
+			hasVisionFlow := false
 			for client_index := range clients {
 			for client_index := range clients {
 				c := clients[client_index].(map[string]any)
 				c := clients[client_index].(map[string]any)
 
 
@@ -2910,6 +2933,9 @@ func (s *InboundService) MigrationRequirements() {
 						c["flow"] = ""
 						c["flow"] = ""
 					}
 					}
 				}
 				}
+				if flow, _ := c["flow"].(string); flow == "xtls-rprx-vision" {
+					hasVisionFlow = true
+				}
 				// Backfill created_at and updated_at
 				// Backfill created_at and updated_at
 				if _, ok := c["created_at"]; !ok {
 				if _, ok := c["created_at"]; !ok {
 					c["created_at"] = time.Now().Unix() * 1000
 					c["created_at"] = time.Now().Unix() * 1000
@@ -2918,6 +2944,15 @@ func (s *InboundService) MigrationRequirements() {
 				newClients = append(newClients, any(c))
 				newClients = append(newClients, any(c))
 			}
 			}
 			settings["clients"] = newClients
 			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, "", "  ")
 			modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
 			if err != nil {
 			if err != nil {
 				return
 				return