Parcourir la source

fix(xray): reject configs xray-core refuses, and check the fixtures against it

The frontend's golden fixtures are the panel's model of an xray config, but
nothing ever asked xray-core whether it would accept them: the snapshots only
prove the Zod schemas agree with themselves. Building every fixture through the
same config builders the panel hands its config to — conf.InboundDetourConfig
for the full-config and AddInbound paths, conf.RouterConfig for
ApplyRoutingConfig, conf.DNSConfig for the dns section — found seven the core
refuses, three of them reachable from the panel's own UI. A refusal is not
scoped to one inbound: the config fails to load and every inbound stays down.

Hysteria: xray-core builds version 2 only, in both the protocol settings and
the transport settings, but the inbound settings schema accepted any version
from 1 up and its comment claimed upstream still supported v1. Both fixtures
carried version 1. The schema now pins 2, GenXrayInboundConfig heals stored
rows on the way out the way it already heals shadowsocks ciphers and wireguard
peers, and the share link drops the dead hysteria:// scheme — the subscription
server already emitted hysteria2:// for the same inbound.

XHTTP uplinkDataPlacement: both transport forms offered "query", which the core
has never accepted for that field (auto and body always, cookie and header in
packet-up mode). Replaced with auto, which was missing, and the default label
now names auto rather than body.

FinalMask items: switching an item to the rand-driven array kind wrote
packet:[] next to the rand. xray-core counts an empty array as a packet and
every item kind is exclusive, so noise answers "len(item.Packet) > 0 &&
item.Rand.To > 0" and header-custom "exactly one item kind must be set". The
editor now clears the packet, and GetXrayConfig strips the residue from rows
already saved with it.

The remaining four were stale fixtures: an xmc mask still on the usernames
shape v26.7.28 replaced with profiles, a fragment mask with no length, and
header-custom and noise items passing an array to the string packet kind — all
shapes the panel's own editors cannot produce.

golden_fixtures_xray_test.go keeps this from drifting again: every fixture in
every category is built through xray-core on each run, with a self-signed pair
standing in for the deployment certificate paths, so the next core bump reports
which fixture it broke.
Sanaei il y a 7 heures
Parent
commit
dc6a16019e
25 fichiers modifiés avec 931 ajouts et 69 suppressions
  1. 7 1
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  2. 1 1
      frontend/src/lib/xray/inbound-defaults.ts
  3. 7 7
      frontend/src/lib/xray/inbound-link.ts
  4. 2 2
      frontend/src/pages/inbounds/form/transport/xhttp.tsx
  5. 2 2
      frontend/src/pages/xray/outbounds/transport/xhttp.tsx
  6. 5 4
      frontend/src/schemas/protocols/inbound/hysteria.ts
  7. 12 12
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  8. 4 4
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  9. 2 2
      frontend/src/test/__snapshots__/inbound-link.test.ts.snap
  10. 1 1
      frontend/src/test/__snapshots__/protocols.test.ts.snap
  11. 4 4
      frontend/src/test/__snapshots__/stream.test.ts.snap
  12. 24 4
      frontend/src/test/golden/fixtures/finalmask/combined.json
  13. 22 5
      frontend/src/test/golden/fixtures/finalmask/tcp-mask.json
  14. 52 10
      frontend/src/test/golden/fixtures/finalmask/udp-mask.json
  15. 12 5
      frontend/src/test/golden/fixtures/inbound-full/hysteria-tls.json
  16. 1 1
      frontend/src/test/golden/fixtures/inbound/hysteria-basic.json
  17. 1 1
      frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json
  18. 2 2
      frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json
  19. 0 1
      frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json
  20. 151 0
      internal/database/model/hysteria_version_test.go
  21. 60 0
      internal/database/model/model.go
  22. 160 0
      internal/web/service/finalmask_rand_packet_test.go
  23. 351 0
      internal/web/service/golden_fixtures_xray_test.go
  24. 46 0
      internal/web/service/inbound.go
  25. 2 0
      internal/web/service/xray.go

+ 7 - 1
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -1146,12 +1146,18 @@ function ItemEditor({
   onRemove?: () => void;
 }) {
   const { t } = useTranslation();
+  /**
+   * Switching to `array` clears the packet instead of emptying it to `[]`:
+   * that branch is rand-driven, and xray-core counts even an empty array as a
+   * packet, rejecting an item that carries both a packet and a rand. That
+   * error fails the whole config, so one such item keeps every inbound offline.
+   */
   const onTypeChange = (v: string) => {
     if (v === 'base64') {
       form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
     } else if (v === 'array') {
       form.setFieldValue([...absoluteItemPath, 'rand'], delayMode === 'string' ? '1-8192' : 0);
-      form.setFieldValue([...absoluteItemPath, 'packet'], []);
+      form.setFieldValue([...absoluteItemPath, 'packet'], undefined);
     } else {
       form.setFieldValue([...absoluteItemPath, 'packet'], '');
     }

+ 1 - 1
frontend/src/lib/xray/inbound-defaults.ts

@@ -174,7 +174,7 @@ export function createDefaultShadowsocksInboundSettings(
 // constructor — the field discriminates v1 vs v2 inside the same settings
 // shape. Callers that explicitly want v1 pass `{ version: 1 }`.
 export interface HysteriaInboundSeed {
-  version?: number;
+  version?: 2;
 }
 
 export function createDefaultHysteriaInboundSettings(

+ 7 - 7
frontend/src/lib/xray/inbound-link.ts

@@ -704,11 +704,12 @@ function hysteriaPinHex(pin: string): string {
   }
 }
 
-// Hysteria share link: hysteria://<auth>@<host>:<port>?<query>#<remark>.
-// The URL scheme is "hysteria2" when settings.version === 2 (hysteria v2
-// AKA hysteria2), "hysteria" otherwise. Salamander obfuscation pulls its
-// password from finalmask.udp[type=salamander] when present; the broader
-// finalmask payload still rides under `fm` like the other links.
+// Hysteria share link: hysteria2://<auth>@<host>:<port>?<query>#<remark>.
+// The scheme is always hysteria2 — xray-core builds version 2 only, so the
+// settings schema pins it there and the subscription server emits the same
+// scheme. Salamander obfuscation pulls its password from
+// finalmask.udp[type=salamander] when present; the broader finalmask payload
+// still rides under `fm` like the other links.
 //
 // Note: legacy genHysteriaLink reads stream.tls.settings.allowInsecure,
 // which isn't a field on TlsStreamSettings.Settings — the guard is always
@@ -727,8 +728,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
   const stream = inbound.streamSettings;
   if (!stream || stream.security !== 'tls') return '';
 
-  const settings = inbound.settings;
-  const scheme = settings.version === 2 ? 'hysteria2' : 'hysteria';
+  const scheme = 'hysteria2';
 
   const params = new URLSearchParams();
   params.set('security', 'tls');

+ 2 - 2
frontend/src/pages/inbounds/form/transport/xhttp.tsx

@@ -265,11 +265,11 @@ export default function XhttpForm() {
           >
             <Select
               options={[
-                { value: '', label: 'Default (body)' },
+                { value: '', label: 'Default (auto)' },
+                { value: 'auto', label: 'auto' },
                 { value: 'body', label: 'body' },
                 { value: 'header', label: 'header' },
                 { value: 'cookie', label: 'cookie' },
-                { value: 'query', label: 'query' },
               ]}
             />
           </FormField>

+ 2 - 2
frontend/src/pages/xray/outbounds/transport/xhttp.tsx

@@ -240,11 +240,11 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
           >
             <Select
               options={[
-                { value: '', label: 'Default (body)' },
+                { value: '', label: 'Default (auto)' },
+                { value: 'auto', label: 'auto' },
                 { value: 'body', label: 'body' },
                 { value: 'header', label: 'header' },
                 { value: 'cookie', label: 'cookie' },
-                { value: 'query', label: 'query' },
               ]}
             />
           </FormField>

+ 5 - 4
frontend/src/schemas/protocols/inbound/hysteria.ts

@@ -1,8 +1,9 @@
 import { z } from 'zod';
 
-// Hysteria v1 inbound (legacy — upstream xray-core kept v1 support but the
-// panel defaults to v2). Each client supplies an `auth` token instead of a
-// UUID/password.
+// Hysteria inbound. Each client supplies an `auth` token instead of a
+// UUID/password. xray-core builds version 2 only — it answers anything else
+// with "version != 2" and rejects the entire config, so a legacy row is
+// coerced rather than carried through.
 export const HysteriaClientSchema = z.object({
   auth: z.string().min(1),
   email: z.string().min(1),
@@ -20,7 +21,7 @@ export const HysteriaClientSchema = z.object({
 export type HysteriaClient = z.infer<typeof HysteriaClientSchema>;
 
 export const HysteriaInboundSettingsSchema = z.object({
-  version: z.number().int().min(1).default(2),
+  version: z.preprocess(() => 2, z.literal(2)).default(2),
   clients: z.array(HysteriaClientSchema).default([]),
 });
 export type HysteriaInboundSettings = z.infer<typeof HysteriaInboundSettingsSchema>;

+ 12 - 12
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -14,6 +14,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses combined byte-stably 1`
   "tcp": [
     {
       "settings": {
+        "length": "10-20",
         "packets": "1-3",
       },
       "type": "fragment",
@@ -145,9 +146,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
           [
             {
               "delay": 0,
-              "packet": [
-                "GET / HTTP/1.1",
-              ],
+              "packet": "GET / HTTP/1.1",
               "type": "str",
             },
           ],
@@ -157,9 +156,7 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
           [
             {
               "delay": 0,
-              "packet": [
-                "HTTP/1.1 200 OK",
-              ],
+              "packet": "HTTP/1.1 200 OK",
               "type": "str",
             },
           ],
@@ -171,8 +168,13 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
       "settings": {
         "hostname": "mc.example.com",
         "password": "s3cr3t",
-        "usernames": [
-          "Dream",
+        "profiles": [
+          {
+            "texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl",
+            "texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
+            "username": "Dream",
+            "uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
+          },
         ],
       },
       "type": "xmc",
@@ -219,13 +221,11 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses udp-mask byte-stably 1`
           {
             "delay": "10-16",
             "rand": "10-20",
-            "type": "rand",
+            "type": "array",
           },
           {
             "delay": "5",
-            "packet": [
-              "ping",
-            ],
+            "packet": "ping",
             "type": "str",
           },
         ],

+ 4 - 4
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -1,6 +1,6 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`] = `
+exports[`InboundSchema (full) fixtures > parses hysteria-tls byte-stably 1`] = `
 {
   "down": 0,
   "enable": true,
@@ -9,7 +9,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
   "listen": "",
   "port": 36715,
   "protocol": "hysteria",
-  "remark": "gina-hysteria-v1",
+  "remark": "gina-hysteria",
   "settings": {
     "clients": [
       {
@@ -25,7 +25,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
         "totalGB": 0,
       },
     ],
-    "version": 1,
+    "version": 2,
   },
   "shareAddr": "",
   "shareAddrStrategy": "node",
@@ -78,7 +78,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
       },
     },
   },
-  "tag": "inbound-hysteria-v1",
+  "tag": "inbound-hysteria",
   "total": 0,
   "up": 0,
 }

+ 2 - 2
frontend/src/test/__snapshots__/inbound-link.test.ts.snap

@@ -1,8 +1,8 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
-exports[`genHysteriaLink > hysteria-v1-tls: byte-stable 1`] = `"hysteria://[email protected]:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
+exports[`genHysteriaLink > hysteria-tls: byte-stable 1`] = `"hysteria2://[email protected]:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
 
-exports[`genInboundLinks orchestrator > hysteria-v1-tls: byte-stable 1`] = `"hysteria://[email protected]:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
+exports[`genInboundLinks orchestrator > hysteria-tls: byte-stable 1`] = `"hysteria2://[email protected]:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`;
 
 exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%[email protected]:8388?type=tcp#parity-test"`;
 

+ 1 - 1
frontend/src/test/__snapshots__/protocols.test.ts.snap

@@ -37,7 +37,7 @@ exports[`InboundSettingsSchema fixtures > parses hysteria-basic byte-stably 1`]
         "totalGB": 0,
       },
     ],
-    "version": 1,
+    "version": 2,
   },
 }
 `;

+ 4 - 4
frontend/src/test/__snapshots__/stream.test.ts.snap

@@ -100,7 +100,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-padding byte-stably
     "xPaddingBytes": "500-1500",
     "xPaddingHeader": "X-Pad",
     "xPaddingKey": "secret-key",
-    "xPaddingMethod": "random",
+    "xPaddingMethod": "tokenish",
     "xPaddingObfsMode": true,
     "xPaddingPlacement": "header",
   },
@@ -114,7 +114,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
     "enableXmux": false,
     "headers": {},
     "host": "edge.example.test",
-    "mode": "auto",
+    "mode": "packet-up",
     "noGRPCHeader": false,
     "noSSEHeader": false,
     "path": "/sp",
@@ -131,7 +131,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-placement byte-stab
     "sessionIDTable": "",
     "uplinkChunkSize": 0,
     "uplinkDataKey": "u",
-    "uplinkDataPlacement": "query",
+    "uplinkDataPlacement": "cookie",
     "uplinkHTTPMethod": "",
     "xPaddingBytes": "100-1000",
     "xPaddingHeader": "",
@@ -184,7 +184,7 @@ exports[`NetworkSettingsSchema fixtures > parses xhttp-extra-tuning byte-stably
       "hMaxRequestTimes": "600-900",
       "hMaxReusableSecs": "1800-3000",
       "maxConcurrency": "16-32",
-      "maxConnections": 4,
+      "maxConnections": 0,
     },
   },
 }

+ 24 - 4
frontend/src/test/golden/fixtures/finalmask/combined.json

@@ -1,15 +1,35 @@
 {
   "tcp": [
-    { "type": "fragment", "settings": { "packets": "1-3" } }
+    {
+      "type": "fragment",
+      "settings": {
+        "packets": "1-3",
+        "length": "10-20"
+      }
+    }
   ],
   "udp": [
-    { "type": "salamander", "settings": { "password": "swordfish" } },
-    { "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } }
+    {
+      "type": "salamander",
+      "settings": {
+        "password": "swordfish"
+      }
+    },
+    {
+      "type": "mkcp-legacy",
+      "settings": {
+        "header": "wireguard",
+        "value": ""
+      }
+    }
   ],
   "quicParams": {
     "congestion": "brutal",
     "brutalUp": "100 mbps",
     "brutalDown": "200 mbps",
-    "udpHop": { "ports": "10000-20000", "interval": "5-10" }
+    "udpHop": {
+      "ports": "10000-20000",
+      "interval": "5-10"
+    }
   }
 }

+ 22 - 5
frontend/src/test/golden/fixtures/finalmask/tcp-mask.json

@@ -9,18 +9,28 @@
         "maxSplit": "0"
       }
     },
-    { "type": "sudoku" },
+    {
+      "type": "sudoku"
+    },
     {
       "type": "header-custom",
       "settings": {
         "clients": [
           [
-            { "type": "str", "packet": ["GET / HTTP/1.1"], "delay": 0 }
+            {
+              "type": "str",
+              "packet": "GET / HTTP/1.1",
+              "delay": 0
+            }
           ]
         ],
         "servers": [
           [
-            { "type": "str", "packet": ["HTTP/1.1 200 OK"], "delay": 0 }
+            {
+              "type": "str",
+              "packet": "HTTP/1.1 200 OK",
+              "delay": 0
+            }
           ]
         ],
         "errors": []
@@ -30,8 +40,15 @@
       "type": "xmc",
       "settings": {
         "hostname": "mc.example.com",
-        "usernames": ["Dream"],
-        "password": "s3cr3t"
+        "password": "s3cr3t",
+        "profiles": [
+          {
+            "username": "Dream",
+            "uuid": "ec70bcaf-702f-4bb8-b48d-276fa52a780c",
+            "texturesValue": "eyJ0aW1lc3RhbXAiOjE3MDAwMDAwMDAwMDAsInByb2ZpbGVJZCI6ImVjNzBiY2FmNzAyZjRiYjhiNDhkMjc2ZmE1MmE3ODBjIn0=",
+            "texturesSignature": "Zm9yLWZpeHR1cmUtdXNlLW9ubHktbm90LWEtcmVhbC1tb2phbmctc2lnbmF0dXJl"
+          }
+        ]
       }
     }
   ]

+ 52 - 10
frontend/src/test/golden/fixtures/finalmask/udp-mask.json

@@ -1,35 +1,77 @@
 {
   "udp": [
-    { "type": "salamander", "settings": { "password": "swordfish" } },
-    { "type": "mkcp-legacy", "settings": { "header": "", "value": "abcdef0123456789" } },
-    { "type": "mkcp-legacy", "settings": { "header": "dns", "value": "cloudflare.com" } },
-    { "type": "mkcp-legacy", "settings": { "header": "wireguard", "value": "" } },
+    {
+      "type": "salamander",
+      "settings": {
+        "password": "swordfish"
+      }
+    },
+    {
+      "type": "mkcp-legacy",
+      "settings": {
+        "header": "",
+        "value": "abcdef0123456789"
+      }
+    },
+    {
+      "type": "mkcp-legacy",
+      "settings": {
+        "header": "dns",
+        "value": "cloudflare.com"
+      }
+    },
+    {
+      "type": "mkcp-legacy",
+      "settings": {
+        "header": "wireguard",
+        "value": ""
+      }
+    },
     {
       "type": "noise",
       "settings": {
         "reset": "60",
         "noise": [
-          { "type": "rand", "rand": "10-20", "delay": "10-16" },
-          { "type": "str", "packet": ["ping"], "delay": "5" }
+          {
+            "type": "array",
+            "rand": "10-20",
+            "delay": "10-16"
+          },
+          {
+            "type": "str",
+            "packet": "ping",
+            "delay": "5"
+          }
         ]
       }
     },
     {
       "type": "xdns",
       "settings": {
-        "domains": ["example.com:txt", "example.org:a"],
-        "resolvers": ["example.com:txt+udp://1.1.1.1:53"]
+        "domains": [
+          "example.com:txt",
+          "example.org:a"
+        ],
+        "resolvers": [
+          "example.com:txt+udp://1.1.1.1:53"
+        ]
       }
     },
     {
       "type": "xicmp",
-      "settings": { "dgram": false, "ips": [] }
+      "settings": {
+        "dgram": false,
+        "ips": []
+      }
     },
     {
       "type": "realm",
       "settings": {
         "url": "realm://[email protected]/my-realm",
-        "stunServers": ["stun.l.google.com:19302", "global.stun.twilio.com:3478"]
+        "stunServers": [
+          "stun.l.google.com:19302",
+          "global.stun.twilio.com:3478"
+        ]
       }
     }
   ]

+ 12 - 5
frontend/src/test/golden/fixtures/inbound-full/hysteria-v1-tls.json → frontend/src/test/golden/fixtures/inbound-full/hysteria-tls.json

@@ -3,15 +3,20 @@
   "up": 0,
   "down": 0,
   "total": 0,
-  "remark": "gina-hysteria-v1",
+  "remark": "gina-hysteria",
   "enable": true,
   "expiryTime": 0,
   "listen": "",
   "port": 36715,
-  "tag": "inbound-hysteria-v1",
+  "tag": "inbound-hysteria",
   "sniffing": {
     "enabled": false,
-    "destOverride": ["http", "tls", "quic", "fakedns"],
+    "destOverride": [
+      "http",
+      "tls",
+      "quic",
+      "fakedns"
+    ],
     "metadataOnly": false,
     "routeOnly": false,
     "ipsExcluded": [],
@@ -19,7 +24,7 @@
   },
   "protocol": "hysteria",
   "settings": {
-    "version": 1,
+    "version": 2,
     "clients": [
       {
         "auth": "hyst-v1-auth-XYZ",
@@ -56,7 +61,9 @@
           "buildChain": false
         }
       ],
-      "alpn": ["h3"],
+      "alpn": [
+        "h3"
+      ],
       "echServerKeys": "",
       "settings": {
         "fingerprint": "chrome",

+ 1 - 1
frontend/src/test/golden/fixtures/inbound/hysteria-basic.json

@@ -1,7 +1,7 @@
 {
   "protocol": "hysteria",
   "settings": {
-    "version": 1,
+    "version": 2,
     "clients": [
       {
         "auth": "hyst3ria-v1-token-XYZ",

+ 1 - 1
frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json

@@ -9,6 +9,6 @@
     "xPaddingKey": "secret-key",
     "xPaddingHeader": "X-Pad",
     "xPaddingPlacement": "header",
-    "xPaddingMethod": "random"
+    "xPaddingMethod": "tokenish"
   }
 }

+ 2 - 2
frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json

@@ -3,12 +3,12 @@
   "xhttpSettings": {
     "path": "/sp",
     "host": "edge.example.test",
-    "mode": "auto",
+    "mode": "packet-up",
     "sessionIDPlacement": "header",
     "sessionIDKey": "X-Session",
     "seqPlacement": "cookie",
     "seqKey": "X-Seq",
-    "uplinkDataPlacement": "query",
+    "uplinkDataPlacement": "cookie",
     "uplinkDataKey": "u"
   }
 }

+ 0 - 1
frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json

@@ -19,7 +19,6 @@
     },
     "xmux": {
       "maxConcurrency": "16-32",
-      "maxConnections": 4,
       "cMaxReuseTimes": 0,
       "hMaxRequestTimes": "600-900",
       "hMaxReusableSecs": "1800-3000",

+ 151 - 0
internal/database/model/hysteria_version_test.go

@@ -0,0 +1,151 @@
+package model
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+)
+
+func settingsVersion(t *testing.T, settings string) any {
+	t.Helper()
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		t.Fatalf("unmarshal settings: %v", err)
+	}
+	return parsed["version"]
+}
+
+func TestHealHysteriaVersion(t *testing.T) {
+	tests := []struct {
+		name        string
+		settings    string
+		wantChanged bool
+		wantVersion any
+	}{
+		{
+			name:        "legacy v1 row",
+			settings:    `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
+			wantChanged: true,
+			wantVersion: float64(2),
+		},
+		{
+			name:        "no version at all",
+			settings:    `{"clients":[{"auth":"tok","email":"a@x"}]}`,
+			wantChanged: true,
+			wantVersion: float64(2),
+		},
+		{
+			name:        "already v2",
+			settings:    `{"version":2,"clients":[]}`,
+			wantChanged: false,
+			wantVersion: float64(2),
+		},
+		{
+			name:        "version as a string",
+			settings:    `{"version":"1","clients":[]}`,
+			wantChanged: true,
+			wantVersion: float64(2),
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			healed, changed := HealHysteriaVersion(tt.settings)
+			if changed != tt.wantChanged {
+				t.Fatalf("changed = %v, want %v", changed, tt.wantChanged)
+			}
+			if got := settingsVersion(t, healed); got != tt.wantVersion {
+				t.Fatalf("version = %#v, want %#v", got, tt.wantVersion)
+			}
+		})
+	}
+}
+
+func TestHealHysteriaVersionKeepsClients(t *testing.T) {
+	healed, changed := HealHysteriaVersion(`{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`)
+	if !changed {
+		t.Fatal("a v1 row must be healed")
+	}
+	if !strings.Contains(healed, `"auth": "tok"`) || !strings.Contains(healed, `"email": "a@x"`) {
+		t.Fatalf("healing dropped client data: %s", healed)
+	}
+}
+
+func TestHealHysteriaVersionLeavesUnparsableSettings(t *testing.T) {
+	const broken = `{"version":1,`
+	healed, changed := HealHysteriaVersion(broken)
+	if changed || healed != broken {
+		t.Fatalf("unparsable settings must be left alone, got changed=%v %q", changed, healed)
+	}
+	if healed, changed := HealHysteriaVersion(""); changed || healed != "" {
+		t.Fatalf("empty settings must be left alone, got changed=%v %q", changed, healed)
+	}
+}
+
+func TestHealHysteriaStreamVersion(t *testing.T) {
+	healed, changed := HealHysteriaStreamVersion(`{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`)
+	if !changed {
+		t.Fatal("a v1 transport must be healed")
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(healed), &parsed); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	hysteria, _ := parsed["hysteriaSettings"].(map[string]any)
+	if hysteria["version"] != float64(2) {
+		t.Fatalf("version = %#v, want 2", hysteria["version"])
+	}
+	if hysteria["udpIdleTimeout"] != float64(60) {
+		t.Fatalf("healing dropped transport settings: %#v", hysteria)
+	}
+}
+
+func TestHealHysteriaStreamVersionWithoutHysteriaSettings(t *testing.T) {
+	const stream = `{"network":"tcp","tcpSettings":{}}`
+	healed, changed := HealHysteriaStreamVersion(stream)
+	if changed || healed != stream {
+		t.Fatalf("a stream without hysteriaSettings must be left alone, got changed=%v %q", changed, healed)
+	}
+}
+
+// TestGenXrayInboundConfigHealsHysteriaVersion is the regression for a stored
+// v1 row: xray-core answers "version != 2" and rejects the whole config, so
+// every other inbound on the server stays offline until the row is fixed.
+func TestGenXrayInboundConfigHealsHysteriaVersion(t *testing.T) {
+	in := Inbound{
+		Protocol:       Hysteria,
+		Port:           36715,
+		Listen:         "127.0.0.1",
+		Tag:            "in-hysteria",
+		Settings:       `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
+		StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
+	}
+	cfg := in.GenXrayInboundConfig()
+
+	if got := settingsVersion(t, string(cfg.Settings)); got != float64(2) {
+		t.Fatalf("generated settings.version = %#v, want 2", got)
+	}
+	var stream map[string]any
+	if err := json.Unmarshal(cfg.StreamSettings, &stream); err != nil {
+		t.Fatalf("unmarshal generated streamSettings: %v", err)
+	}
+	hysteria, _ := stream["hysteriaSettings"].(map[string]any)
+	if hysteria["version"] != float64(2) {
+		t.Fatalf("generated hysteriaSettings.version = %#v, want 2", hysteria["version"])
+	}
+
+	if !strings.Contains(in.Settings, `"version":1`) {
+		t.Fatal("the stored row must keep its own value; only the generated config is healed")
+	}
+}
+
+func TestGenXrayInboundConfigLeavesOtherProtocolsAlone(t *testing.T) {
+	in := Inbound{
+		Protocol: VLESS,
+		Port:     443,
+		Tag:      "in-vless",
+		Settings: `{"clients":[],"decryption":"none"}`,
+	}
+	if got := settingsVersion(t, string(in.GenXrayInboundConfig().Settings)); got != nil {
+		t.Fatalf("a non-hysteria inbound must not gain a version key, got %#v", got)
+	}
+}

+ 60 - 0
internal/database/model/model.go

@@ -229,6 +229,57 @@ func jsonStringFieldFromRaw(r json.RawMessage) string {
 	return string(trimmed)
 }
 
+// hysteriaConfigVersion is the only hysteria version xray-core builds. Both
+// the protocol settings and the transport settings answer anything else with
+// "version != 2", and that error rejects the whole config — every other
+// inbound on the server goes down with it, not just the hysteria one.
+const hysteriaConfigVersion = 2
+
+// HealHysteriaVersion pins a hysteria inbound's settings.version to the
+// version xray-core accepts. Rows written before the panel settled on v2, or
+// through the API and the raw JSON editor, can still carry the legacy 1 or no
+// version at all, either of which stops the core from starting.
+func HealHysteriaVersion(settings string) (string, bool) {
+	return healVersionField(settings, nil)
+}
+
+// HealHysteriaStreamVersion does the same for the transport half,
+// streamSettings.hysteriaSettings.version, which xray-core validates
+// separately. An absent hysteriaSettings object is left alone.
+func HealHysteriaStreamVersion(streamSettings string) (string, bool) {
+	return healVersionField(streamSettings, []string{"hysteriaSettings"})
+}
+
+// healVersionField rewrites the "version" key of the object reached by path to
+// hysteriaConfigVersion, reporting whether anything changed. A path that does
+// not resolve to an object leaves the input untouched.
+func healVersionField(raw string, path []string) (string, bool) {
+	if raw == "" {
+		return raw, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
+		return raw, false
+	}
+	target := parsed
+	for _, key := range path {
+		next, ok := target[key].(map[string]any)
+		if !ok {
+			return raw, false
+		}
+		target = next
+	}
+	if version, ok := target["version"].(float64); ok && version == hysteriaConfigVersion {
+		return raw, false
+	}
+	target["version"] = hysteriaConfigVersion
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return raw, false
+	}
+	return string(out), true
+}
+
 // StripInboundXhttpClientFields removes xHTTP knobs that belong on the
 // client dialer and subscription share-link extras only. xray-core's XHTTP
 // inbound listener does not consume them; the panel still stores them on
@@ -298,11 +349,20 @@ func (i *Inbound) GenXrayInboundConfig() *xray.InboundConfig {
 		if converted, ok := WireguardClientsToPeers(settings); ok {
 			settings = converted
 		}
+	case Hysteria:
+		if healed, ok := HealHysteriaVersion(settings); ok {
+			settings = healed
+		}
 	}
 	streamSettings := i.StreamSettings
 	if stripped, ok := StripInboundXhttpClientFields(streamSettings); ok {
 		streamSettings = stripped
 	}
+	if i.Protocol == Hysteria {
+		if healed, ok := HealHysteriaStreamVersion(streamSettings); ok {
+			streamSettings = healed
+		}
+	}
 	return &xray.InboundConfig{
 		Listen:         json_util.RawMessage(listen),
 		Port:           i.Port,

+ 160 - 0
internal/web/service/finalmask_rand_packet_test.go

@@ -0,0 +1,160 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func streamWithNoiseItem(t *testing.T, item map[string]any) map[string]any {
+	t.Helper()
+	return map[string]any{
+		"network": "tcp",
+		"finalmask": map[string]any{
+			"udp": []any{map[string]any{
+				"type": "noise",
+				"settings": map[string]any{
+					"reset": "60",
+					"noise": []any{item},
+				},
+			}},
+		},
+	}
+}
+
+func noiseItem(t *testing.T, stream map[string]any) map[string]any {
+	t.Helper()
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	udp, _ := finalmask["udp"].([]any)
+	mask, _ := udp[0].(map[string]any)
+	settings, _ := mask["settings"].(map[string]any)
+	noise, _ := settings["noise"].([]any)
+	item, _ := noise[0].(map[string]any)
+	return item
+}
+
+// TestDropEmptyRandPacketsClearsEditorResidue is the regression for the mask
+// editor writing packet:[] alongside a rand. xray-core counts the empty array
+// as a packet and refuses the config, which keeps every inbound offline.
+func TestDropEmptyRandPacketsClearsEditorResidue(t *testing.T) {
+	stream := streamWithNoiseItem(t, map[string]any{
+		"type":   "array",
+		"rand":   "1-8192",
+		"packet": []any{},
+		"delay":  "5",
+	})
+
+	if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 1 {
+		t.Fatalf("cleared = %d, want 1", cleared)
+	}
+	item := noiseItem(t, stream)
+	if _, present := item["packet"]; present {
+		t.Fatalf("packet survived: %#v", item)
+	}
+	if item["rand"] != "1-8192" || item["delay"] != "5" {
+		t.Fatalf("healing changed the mask: %#v", item)
+	}
+}
+
+func TestDropEmptyRandPacketsLeavesRealPacketsAlone(t *testing.T) {
+	tests := []struct {
+		name string
+		item map[string]any
+	}{
+		{"packet without a rand", map[string]any{"type": "array", "packet": []any{1.0, 2.0}, "rand": 0.0}},
+		{"empty packet without a rand", map[string]any{"type": "array", "packet": []any{}}},
+		{"empty packet with a zero rand", map[string]any{"type": "array", "packet": []any{}, "rand": 0.0}},
+		{"empty packet with a zero range", map[string]any{"type": "array", "packet": []any{}, "rand": "0-0"}},
+		{"string packet", map[string]any{"type": "str", "packet": "ping", "rand": "1-10"}},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			stream := streamWithNoiseItem(t, tt.item)
+			if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
+				t.Fatalf("cleared = %d, want the item left alone", cleared)
+			}
+			if _, present := noiseItem(t, stream)["packet"]; !present {
+				t.Fatal("packet was dropped")
+			}
+		})
+	}
+}
+
+// TestDropEmptyRandPacketsReachesNestedItems covers header-custom, whose items
+// sit two arrays deep and are subject to the same exclusive-kind rule.
+func TestDropEmptyRandPacketsReachesNestedItems(t *testing.T) {
+	stream := map[string]any{
+		"finalmask": map[string]any{
+			"tcp": []any{map[string]any{
+				"type": "header-custom",
+				"settings": map[string]any{
+					"clients": []any{[]any{map[string]any{"type": "array", "rand": 64.0, "packet": []any{}}}},
+					"servers": []any{[]any{map[string]any{"type": "array", "rand": 32.0, "packet": []any{}}}},
+				},
+			}},
+		},
+	}
+	if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 2 {
+		t.Fatalf("cleared = %d, want 2", cleared)
+	}
+}
+
+func TestDropEmptyRandPacketsIgnoresMissingFinalMask(t *testing.T) {
+	stream := map[string]any{"network": "tcp"}
+	if cleared := dropEmptyRandPackets(stream["finalmask"]); cleared != 0 {
+		t.Fatalf("cleared = %d, want 0", cleared)
+	}
+}
+
+// TestHealedConfigsBuildInXray closes the loop on both heals: the rows xray
+// refuses outright must build once the panel has healed them.
+func TestHealedConfigsBuildInXray(t *testing.T) {
+	t.Run("hysteria v1 row", func(t *testing.T) {
+		in := model.Inbound{
+			Protocol:       model.Hysteria,
+			Port:           36715,
+			Listen:         "127.0.0.1",
+			Tag:            "in-hysteria",
+			Settings:       `{"version":1,"clients":[{"auth":"tok","email":"a@x"}]}`,
+			StreamSettings: `{"network":"hysteria","hysteriaSettings":{"version":1,"udpIdleTimeout":60}}`,
+		}
+
+		raw, err := json.Marshal(in.GenXrayInboundConfig())
+		if err != nil {
+			t.Fatalf("marshal generated inbound: %v", err)
+		}
+		var healed map[string]any
+		if err := json.Unmarshal(raw, &healed); err != nil {
+			t.Fatalf("decode generated inbound: %v", err)
+		}
+		assertXrayAccepts(t, "the healed hysteria inbound", buildGoldenInbound(t, healed))
+
+		var unhealed map[string]any
+		if err := json.Unmarshal([]byte(`{
+			"tag":"in-hysteria","listen":"127.0.0.1","port":36715,"protocol":"hysteria",
+			"settings":`+in.Settings+`,"streamSettings":`+in.StreamSettings+`}`), &unhealed); err != nil {
+			t.Fatalf("decode raw inbound: %v", err)
+		}
+		if err := buildGoldenInbound(t, unhealed); err == nil {
+			t.Fatal("the unhealed v1 row is expected to be refused; the heal is what makes it buildable")
+		}
+	})
+
+	t.Run("noise item with an empty packet", func(t *testing.T) {
+		item := map[string]any{"type": "array", "rand": "1-8192", "packet": []any{}, "delay": "5"}
+		stream := streamWithNoiseItem(t, item)
+		inbound := map[string]any{
+			"tag": "in-vless", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
+			"settings":       map[string]any{"clients": []any{}, "decryption": "none"},
+			"streamSettings": stream,
+		}
+		if err := buildGoldenInbound(t, inbound); err == nil {
+			t.Fatal("xray-core is expected to refuse a packet and a rand on one item")
+		}
+
+		dropEmptyRandPackets(stream["finalmask"])
+		inbound["streamSettings"] = stream
+		assertXrayAccepts(t, "the healed noise mask", buildGoldenInbound(t, inbound))
+	})
+}

+ 351 - 0
internal/web/service/golden_fixtures_xray_test.go

@@ -0,0 +1,351 @@
+package service
+
+import (
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/json"
+	"encoding/pem"
+	"math/big"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"github.com/xtls/xray-core/infra/conf"
+)
+
+// The frontend's golden fixtures are the panel's model of an xray config: the
+// Zod snapshots pin what the forms parse and emit. Parsing proves the panel
+// agrees with itself, not that xray-core would accept the result, so every
+// fixture is also built here through the very config builders the panel hands
+// its config to — conf.InboundDetourConfig for the full-config and AddInbound
+// paths, conf.RouterConfig for ApplyRoutingConfig, conf.DNSConfig for the dns
+// section. A fixture xray-core refuses is a config the panel would let an
+// admin save and then fail to start the core with, taking every inbound down.
+//
+// mtproto is excluded: it is served by the bundled mtg-multi sidecar, not by
+// xray, so xray-core has no config id for it.
+
+func goldenFixtureDir(t *testing.T, category string) string {
+	t.Helper()
+	dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category))
+	if err != nil {
+		t.Fatalf("resolve fixture dir: %v", err)
+	}
+	return dir
+}
+
+// goldenFixtures returns every fixture in a category as name -> decoded object.
+func goldenFixtures(t *testing.T, category string) map[string]map[string]any {
+	t.Helper()
+	dir := goldenFixtureDir(t, category)
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		t.Fatalf("read %s: %v", dir, err)
+	}
+	out := make(map[string]map[string]any)
+	names := make([]string, 0, len(entries))
+	for _, entry := range entries {
+		if filepath.Ext(entry.Name()) != ".json" {
+			continue
+		}
+		names = append(names, entry.Name())
+	}
+	sort.Strings(names)
+	if len(names) == 0 {
+		t.Fatalf("no fixtures under %s", dir)
+	}
+	for _, name := range names {
+		raw, err := os.ReadFile(filepath.Join(dir, name))
+		if err != nil {
+			t.Fatalf("read %s: %v", name, err)
+		}
+		var obj map[string]any
+		if err := json.Unmarshal(raw, &obj); err != nil {
+			t.Fatalf("unmarshal %s: %v", name, err)
+		}
+		out[strings.TrimSuffix(name, ".json")] = obj
+	}
+	return out
+}
+
+// writeTestCertificate writes a self-signed certificate and key, returning both
+// paths. The TLS fixtures point certificateFile/keyFile at deployment paths
+// that do not exist here, and xray-core reads them while building.
+func writeTestCertificate(t *testing.T) (certPath, keyPath string) {
+	t.Helper()
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		t.Fatalf("generate key: %v", err)
+	}
+	template := x509.Certificate{
+		SerialNumber: big.NewInt(1),
+		Subject:      pkix.Name{CommonName: "golden-fixture.test"},
+		NotBefore:    time.Now().Add(-time.Hour),
+		NotAfter:     time.Now().Add(24 * time.Hour),
+		DNSNames:     []string{"golden-fixture.test"},
+	}
+	der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
+	if err != nil {
+		t.Fatalf("create certificate: %v", err)
+	}
+	keyDER, err := x509.MarshalECPrivateKey(key)
+	if err != nil {
+		t.Fatalf("marshal key: %v", err)
+	}
+
+	dir := t.TempDir()
+	certPath = filepath.Join(dir, "fixture.crt")
+	keyPath = filepath.Join(dir, "fixture.key")
+	certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
+	keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+	if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
+		t.Fatalf("write certificate: %v", err)
+	}
+	if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
+		t.Fatalf("write key: %v", err)
+	}
+	return certPath, keyPath
+}
+
+// repointCertificateFiles rewrites every certificateFile/keyFile reference to
+// the generated pair, so a fixture is judged on its shape rather than on paths
+// that only exist on a deployed server.
+func repointCertificateFiles(node any, certPath, keyPath string) {
+	switch value := node.(type) {
+	case map[string]any:
+		for key, child := range value {
+			switch key {
+			case "certificateFile":
+				if _, ok := child.(string); ok {
+					value[key] = certPath
+					continue
+				}
+			case "keyFile":
+				if _, ok := child.(string); ok {
+					value[key] = keyPath
+					continue
+				}
+			}
+			repointCertificateFiles(child, certPath, keyPath)
+		}
+	case []any:
+		for _, child := range value {
+			repointCertificateFiles(child, certPath, keyPath)
+		}
+	}
+}
+
+// assertXrayAccepts fails unless xray-core built the fixture. Fixtures naming
+// geoip:/geosite: need the dat files the panel ships next to the xray binary;
+// where they are absent the loader fails on the missing file rather than on the
+// fixture, so those are skipped instead of reported as broken.
+func assertXrayAccepts(t *testing.T, subject string, err error) {
+	t.Helper()
+	if err == nil {
+		return
+	}
+	if isMissingGeoAssetErr(err) {
+		t.Skipf("geo data files not available, cannot judge %s: %v", subject, err)
+	}
+	t.Fatalf("xray-core refuses %s: %v", subject, err)
+}
+
+func buildGoldenInbound(t *testing.T, inbound map[string]any) error {
+	t.Helper()
+	certPath, keyPath := writeTestCertificate(t)
+	repointCertificateFiles(inbound, certPath, keyPath)
+
+	raw, err := json.Marshal(inbound)
+	if err != nil {
+		t.Fatalf("marshal inbound: %v", err)
+	}
+	detour := new(conf.InboundDetourConfig)
+	if err := json.Unmarshal(raw, detour); err != nil {
+		return err
+	}
+	_, err = detour.Build()
+	return err
+}
+
+// TestGoldenInboundFixturesBuildInXray wraps each protocol fixture in a minimal
+// inbound and builds it.
+func TestGoldenInboundFixturesBuildInXray(t *testing.T) {
+	for name, fixture := range goldenFixtures(t, "inbound") {
+		if protocol, _ := fixture["protocol"].(string); protocol == string(model.MTProto) {
+			continue
+		}
+		t.Run(name, func(t *testing.T) {
+			inbound := map[string]any{
+				"tag":      "golden-in",
+				"listen":   "127.0.0.1",
+				"port":     8443,
+				"protocol": fixture["protocol"],
+				"settings": fixture["settings"],
+			}
+			assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
+		})
+	}
+}
+
+// TestGoldenInboundFullFixturesBuildInXray runs the complete panel Inbound
+// model through GenXrayInboundConfig, the conversion both the full-config and
+// the live AddInbound paths use, and builds the result.
+func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) {
+	for name, fixture := range goldenFixtures(t, "inbound-full") {
+		protocol, _ := fixture["protocol"].(string)
+		if protocol == string(model.MTProto) {
+			continue
+		}
+		t.Run(name, func(t *testing.T) {
+			section := func(key string) string {
+				value, ok := fixture[key]
+				if !ok || value == nil {
+					return ""
+				}
+				raw, err := json.Marshal(value)
+				if err != nil {
+					t.Fatalf("marshal %s: %v", key, err)
+				}
+				return string(raw)
+			}
+			port := 0
+			if p, ok := fixture["port"].(float64); ok {
+				port = int(p)
+			}
+			listen, _ := fixture["listen"].(string)
+			tag, _ := fixture["tag"].(string)
+
+			ib := &model.Inbound{
+				Protocol:       model.Protocol(protocol),
+				Port:           port,
+				Listen:         listen,
+				Tag:            tag,
+				Settings:       section("settings"),
+				StreamSettings: section("streamSettings"),
+				Sniffing:       section("sniffing"),
+			}
+			raw, err := json.Marshal(ib.GenXrayInboundConfig())
+			if err != nil {
+				t.Fatalf("marshal generated inbound: %v", err)
+			}
+			var generated map[string]any
+			if err := json.Unmarshal(raw, &generated); err != nil {
+				t.Fatalf("decode generated inbound: %v", err)
+			}
+			assertXrayAccepts(t, "the generated inbound", buildGoldenInbound(t, generated))
+		})
+	}
+}
+
+// TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole
+// stream sections, the security block, sockopt and finalmask — to an inbound.
+func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
+	for _, category := range []string{"stream", "security", "sockopt", "finalmask"} {
+		for name, fixture := range goldenFixtures(t, category) {
+			t.Run(category+"/"+name, func(t *testing.T) {
+				stream := map[string]any{"network": "tcp"}
+				switch category {
+				case "stream":
+					stream = fixture
+				case "security":
+					for key, value := range fixture {
+						stream[key] = value
+					}
+				case "sockopt":
+					stream["sockopt"] = fixture
+				case "finalmask":
+					stream["finalmask"] = fixture
+				}
+				inbound := map[string]any{
+					"tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
+					"settings": map[string]any{
+						"clients":    []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}},
+						"decryption": "none",
+					},
+					"streamSettings": stream,
+				}
+				assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
+			})
+		}
+	}
+}
+
+func buildGoldenRouting(routing map[string]any) error {
+	raw, err := json.Marshal(routing)
+	if err != nil {
+		return err
+	}
+	router := new(conf.RouterConfig)
+	if err := json.Unmarshal(raw, router); err != nil {
+		return err
+	}
+	_, err = router.Build()
+	return err
+}
+
+// TestGoldenRoutingFixturesBuildInXray builds the rule and balancer fixtures
+// through the router config ApplyRoutingConfig hands to the running core.
+func TestGoldenRoutingFixturesBuildInXray(t *testing.T) {
+	for name, rule := range goldenFixtures(t, "rule") {
+		t.Run("rule/"+name, func(t *testing.T) {
+			assertXrayAccepts(t, "this rule", buildGoldenRouting(map[string]any{
+				"domainStrategy": "AsIs",
+				"rules":          []any{rule},
+				"balancers":      []any{map[string]any{"tag": "balancer-load", "selector": []any{"proxy-"}}},
+			}))
+		})
+	}
+
+	for name, balancer := range goldenFixtures(t, "balancer") {
+		t.Run("balancer/"+name, func(t *testing.T) {
+			assertXrayAccepts(t, "this balancer", buildGoldenRouting(map[string]any{
+				"domainStrategy": "AsIs",
+				"balancers":      []any{balancer},
+				"rules": []any{map[string]any{
+					"type": "field", "port": "443", "balancerTag": balancer["tag"],
+				}},
+			}))
+		})
+	}
+}
+
+// TestGoldenDNSFixturesBuildInXray builds the dns section, and each dns-server
+// fixture inside one.
+func TestGoldenDNSFixturesBuildInXray(t *testing.T) {
+	build := func(dns map[string]any) error {
+		raw, err := json.Marshal(dns)
+		if err != nil {
+			return err
+		}
+		dnsConf := new(conf.DNSConfig)
+		if err := json.Unmarshal(raw, dnsConf); err != nil {
+			return err
+		}
+		_, err = dnsConf.Build()
+		return err
+	}
+
+	for name, fixture := range goldenFixtures(t, "dns") {
+		t.Run("dns/"+name, func(t *testing.T) {
+			assertXrayAccepts(t, "this dns section", build(fixture))
+		})
+	}
+	for name, server := range goldenFixtures(t, "dns-server") {
+		t.Run("dns-server/"+name, func(t *testing.T) {
+			assertXrayAccepts(t, "this dns server", build(map[string]any{"servers": []any{server}}))
+		})
+	}
+}
+
+func isMissingGeoAssetErr(err error) bool {
+	msg := err.Error()
+	return strings.Contains(msg, "geoip.dat") || strings.Contains(msg, "geosite.dat")
+}

+ 46 - 0
internal/web/service/inbound.go

@@ -713,6 +713,52 @@ func stripIncompleteXmcMasks(stream map[string]any) int {
 	return dropped
 }
 
+// dropEmptyRandPackets removes the leftover empty "packet" from finalmask
+// items that also carry a rand, and reports how many it cleared.
+//
+// xray-core treats even an empty array as a packet, and every item kind is
+// exclusive: noise refuses "len(item.Packet) > 0 && item.Rand.To > 0" and
+// header-custom refuses "exactly one item kind must be set". Either error
+// fails the whole config build, so one such item keeps every inbound offline.
+// The panel's mask editor wrote that pair whenever an item was switched to the
+// rand-driven array kind, so stored rows carry it; clearing an empty packet
+// changes nothing about the mask the admin configured.
+func dropEmptyRandPackets(node any) int {
+	switch value := node.(type) {
+	case map[string]any:
+		cleared := 0
+		if packet, ok := value["packet"].([]any); ok && len(packet) == 0 && randIsSet(value["rand"]) {
+			delete(value, "packet")
+			cleared++
+		}
+		for _, child := range value {
+			cleared += dropEmptyRandPackets(child)
+		}
+		return cleared
+	case []any:
+		cleared := 0
+		for _, child := range value {
+			cleared += dropEmptyRandPackets(child)
+		}
+		return cleared
+	default:
+		return 0
+	}
+}
+
+// randIsSet reports whether a finalmask item's rand selects a random packet.
+// It is a number on header-custom items and a dash-range string on noise ones.
+func randIsSet(value any) bool {
+	switch rand := value.(type) {
+	case float64:
+		return rand > 0
+	case string:
+		return rand != "" && rand != "0" && rand != "0-0"
+	default:
+		return false
+	}
+}
+
 // validateFinalMaskXmcProfiles rejects an xmc finalmask without complete
 // profiles at save time, so the admin gets a targeted error instead of a core
 // that refuses to start (or, after GetXrayConfig heals it, an inbound quietly

+ 2 - 0
internal/web/service/xray.go

@@ -281,6 +281,8 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 				delete(stream, "finalmask")
 			}
 
+			dropEmptyRandPackets(stream["finalmask"])
+
 			if dropped := stripIncompleteXmcMasks(stream); dropped > 0 {
 				logger.Warningf("Inbound %q: dropping %d XMC finalmask mask(s) without complete Minecraft profiles — reconfigure them to restore the obfuscation (see XTLS/Xray-core#6487)", inbound.Tag, dropped)
 			}