4 Commits fd17255f1d ... 4605f00a15

Tác giả SHA1 Thông báo Ngày
  Sanaei 4605f00a15 fix(nodes): keep the credential-presence flag on the node heartbeat push 4 giờ trước cách đây
  Sanaei dc6a16019e fix(xray): reject configs xray-core refuses, and check the fixtures against it 7 giờ trước cách đây
  Sanaei fea6a20f7c fix(xray): stop the runtime user API from crashing xray-core 8 giờ trước cách đây
  Sanaei 7f7b7e16a4 feat(xray): update xray-core to v26.7.28 and adapt panel 9 giờ trước cách đây
43 tập tin đã thay đổi với 2492 bổ sung128 xóa
  1. 2 2
      .github/workflows/release.yml
  2. 2 1
      .gitignore
  3. 1 1
      DockerInit.sh
  4. 134 13
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  5. 1 1
      frontend/src/lib/xray/inbound-defaults.ts
  6. 7 7
      frontend/src/lib/xray/inbound-link.ts
  7. 2 2
      frontend/src/pages/inbounds/form/transport/xhttp.tsx
  8. 2 2
      frontend/src/pages/xray/outbounds/transport/xhttp.tsx
  9. 5 4
      frontend/src/schemas/protocols/inbound/hysteria.ts
  10. 5 3
      frontend/src/schemas/protocols/stream/xhttp.ts
  11. 12 12
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  12. 4 4
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  13. 2 2
      frontend/src/test/__snapshots__/inbound-link.test.ts.snap
  14. 1 1
      frontend/src/test/__snapshots__/protocols.test.ts.snap
  15. 4 4
      frontend/src/test/__snapshots__/stream.test.ts.snap
  16. 45 1
      frontend/src/test/finalmask.test.ts
  17. 24 4
      frontend/src/test/golden/fixtures/finalmask/combined.json
  18. 22 5
      frontend/src/test/golden/fixtures/finalmask/tcp-mask.json
  19. 52 10
      frontend/src/test/golden/fixtures/finalmask/udp-mask.json
  20. 12 5
      frontend/src/test/golden/fixtures/inbound-full/hysteria-tls.json
  21. 1 1
      frontend/src/test/golden/fixtures/inbound/hysteria-basic.json
  22. 1 1
      frontend/src/test/golden/fixtures/stream/xhttp-extra-padding.json
  23. 2 2
      frontend/src/test/golden/fixtures/stream/xhttp-extra-placement.json
  24. 0 1
      frontend/src/test/golden/fixtures/stream/xhttp-extra-tuning.json
  25. 3 3
      frontend/src/test/stream-wire-normalize.test.ts
  26. 1 1
      go.mod
  27. 2 2
      go.sum
  28. 151 0
      internal/database/model/hysteria_version_test.go
  29. 60 0
      internal/database/model/model.go
  30. 1 1
      internal/web/job/node_heartbeat_job.go
  31. 2 2
      internal/web/service/client_inbound_apply.go
  32. 160 0
      internal/web/service/finalmask_rand_packet_test.go
  33. 351 0
      internal/web/service/golden_fixtures_xray_test.go
  34. 191 0
      internal/web/service/inbound.go
  35. 108 0
      internal/web/service/inbound_autorenew_shadowsocks_test.go
  36. 204 0
      internal/web/service/inbound_finalmask_xmc_test.go
  37. 23 2
      internal/web/service/inbound_traffic.go
  38. 6 0
      internal/web/service/xray.go
  39. 0 3
      internal/web/websocket/notifier.go
  40. 98 25
      internal/xray/api.go
  41. 149 0
      internal/xray/api_shadowsocks_test.go
  42. 192 0
      internal/xray/api_traffic_test.go
  43. 447 0
      internal/xray/api_users_e2e_test.go

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

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

+ 2 - 1
.gitignore

@@ -2,7 +2,8 @@
 .idea/
 .vscode/
 .cursor/
-.claude/*
+.specify/
+.claude/
 .cache/
 .sync*
 

+ 1 - 1
DockerInit.sh

@@ -32,7 +32,7 @@ if [ -z "$MTG_MULTI_VER" ]; then
 fi
 mkdir -p build/bin
 cd build/bin
-curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.11/Xray-linux-${ARCH}.zip"
+curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.7.28/Xray-linux-${ARCH}.zip"
 unzip "Xray-linux-${ARCH}.zip"
 rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
 mv xray "xray-linux-${FNAME}"

+ 134 - 13
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -82,12 +82,43 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
     case 'header-custom':
       return { clients: [], servers: [] };
     case 'xmc':
-      return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
+      return { hostname: '', profiles: [defaultXmcProfile()], password: RandomUtil.randomLowerAndNum(16) };
     default:
       return {};
   }
 }
 
+function defaultXmcProfile(): Record<string, unknown> {
+  return { username: '', uuid: '', texturesValue: '', texturesSignature: '' };
+}
+
+// xray-core #6487 replaced the xmc mask's `usernames` string list with
+// `profiles` objects carrying a Mojang-signed session profile, and dropped the
+// "default to Dream" fallback so at least one complete profile is now
+// mandatory. The signature can only come from Mojang's session server, so a
+// legacy username cannot be upgraded automatically — carry it into a profile
+// stub instead, which keeps the operator's player names visible and leaves the
+// per-field validators pointing at exactly what still has to be filled in.
+export function migrateXmcSettings(settings: Record<string, unknown>): { next: Record<string, unknown>; changed: boolean } {
+  const out: Record<string, unknown> = { ...settings };
+  let changed = false;
+  if (!Array.isArray(out.profiles) && Array.isArray(out.usernames)) {
+    out.profiles = out.usernames
+      .filter((name): name is string => typeof name === 'string' && name.trim() !== '')
+      .map((name) => ({ ...defaultXmcProfile(), username: name }));
+    changed = true;
+  }
+  if ('usernames' in out) {
+    delete out.usernames;
+    changed = true;
+  }
+  if (!Array.isArray(out.profiles)) {
+    out.profiles = [];
+    changed = true;
+  }
+  return { next: out, changed };
+}
+
 // xray-core #6334 replaced a fragment mask's single `length`/`delay` ranges
 // with `lengths`/`delays` arrays (the singular keys remain in core only as a
 // fallback). Lift any legacy singular value into a one-element array so the
@@ -171,8 +202,8 @@ function defaultUdpHop(): Record<string, unknown> {
 export default function FinalMaskForm({ name, network, protocol, form, showAll = false }: FinalMaskFormProps) {
   const base = asPath(name);
 
-  // Migrate legacy single-range fragment masks to the per-segment arrays once
-  // on mount so configs saved before #6334 render in the list UI.
+  // Migrate legacy TCP mask shapes once on mount so configs saved before
+  // #6334 (fragment ranges) and #6487 (xmc profiles) render in the list UI.
   const migratedRef = useRef(false);
   useEffect(() => {
     if (migratedRef.current) return;
@@ -183,8 +214,12 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
     const next = tcp.map((mask) => {
       if (!mask || typeof mask !== 'object') return mask;
       const m = mask as Record<string, unknown>;
-      if (m.type !== 'fragment' || !m.settings || typeof m.settings !== 'object') return mask;
-      const { next: migrated, changed } = migrateFragmentSettings(m.settings as Record<string, unknown>);
+      if (m.type !== 'fragment' && m.type !== 'xmc') return mask;
+      if (!m.settings || typeof m.settings !== 'object') return mask;
+      const settings = m.settings as Record<string, unknown>;
+      const { next: migrated, changed } = m.type === 'fragment'
+        ? migrateFragmentSettings(settings)
+        : migrateXmcSettings(settings);
       if (!changed) return mask;
       anyChanged = true;
       return { ...m, settings: migrated };
@@ -380,13 +415,7 @@ function TcpMaskItem({
                 <Form.Item label="Hostname" name={[fieldName, 'settings', 'hostname']}>
                   <Input placeholder="Server address mimicked in the handshake" />
                 </Form.Item>
-                <Form.Item
-                  label="Usernames"
-                  name={[fieldName, 'settings', 'usernames']}
-                  extra="Player names offered to probes; core defaults to Dream when empty."
-                >
-                  <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
-                </Form.Item>
+                <XmcProfilesList tcpFieldName={fieldName} />
                 <Form.Item label="Password" required>
                   <Space.Compact block>
                     <Form.Item
@@ -528,6 +557,92 @@ function getDeep(obj: unknown, path: (string | number)[]): unknown {
   return cur;
 }
 
+// Mojang hands the profile UUID back undashed from the session server and
+// dashed from most other endpoints; xray-core parses either, so accept both
+// rather than forcing the operator to reformat what they pasted.
+const XMC_UUID_PATTERN = /^(?:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|[0-9a-fA-F]{32})$/;
+const XMC_USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
+
+function validateXmcUsername(_rule: unknown, value: unknown): Promise<void> {
+  if (typeof value === 'string' && XMC_USERNAME_PATTERN.test(value)) return Promise.resolve();
+  return Promise.reject(new Error('3-16 characters, letters/digits/underscore only'));
+}
+
+function validateXmcUuid(_rule: unknown, value: unknown): Promise<void> {
+  if (typeof value === 'string' && XMC_UUID_PATTERN.test(value.trim())) return Promise.resolve();
+  return Promise.reject(new Error('Enter the profile UUID (dashed or 32 hex characters)'));
+}
+
+// Each mask needs at least one fully signed profile since xray-core #6487 —
+// an empty or partial list makes the core reject the whole config, so the
+// panel blocks the save here rather than letting the backend drop the mask.
+function XmcProfilesList({ tcpFieldName }: { tcpFieldName: number }) {
+  const { t } = useTranslation();
+  return (
+    <Form.List name={[tcpFieldName, 'settings', 'profiles']}>
+      {(profiles, { add, remove }) => (
+        <>
+          <Form.Item
+            label="Profiles"
+            extra="Signed Minecraft session profiles; resolve the UUID by username, then fetch the profile with unsigned=false."
+          >
+            <Button
+              type="primary"
+              size="small"
+              icon={<PlusOutlined />}
+              aria-label={t('add')}
+              onClick={() => add(defaultXmcProfile())}
+            />
+          </Form.Item>
+          {profiles.map((profile, idx) => (
+            <div key={profile.key}>
+              <Divider style={{ margin: 0 }}>
+                Profile {idx + 1}
+                <DeleteOutlined
+                  className="danger-icon"
+                  role="button"
+                  tabIndex={0}
+                  aria-label={t('remove')}
+                  onClick={() => remove(profile.name)}
+                  onKeyDown={activateOnKey(() => remove(profile.name))}
+                />
+              </Divider>
+              <Form.Item
+                label="Username"
+                name={[profile.name, 'username']}
+                rules={[{ validator: validateXmcUsername }]}
+              >
+                <Input placeholder="Notch" />
+              </Form.Item>
+              <Form.Item
+                label="UUID"
+                name={[profile.name, 'uuid']}
+                rules={[{ validator: validateXmcUuid }]}
+              >
+                <Input placeholder="069a79f4-44e9-4726-a5be-fca90e38aaf5" />
+              </Form.Item>
+              <Form.Item
+                label="Textures Value"
+                name={[profile.name, 'texturesValue']}
+                rules={[{ required: true, message: 'Textures value is required' }]}
+              >
+                <Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 value from the session profile" />
+              </Form.Item>
+              <Form.Item
+                label="Textures Signature"
+                name={[profile.name, 'texturesSignature']}
+                rules={[{ required: true, message: 'Textures signature is required' }]}
+              >
+                <Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="Base64 signature from the session profile" />
+              </Form.Item>
+            </div>
+          ))}
+        </>
+      )}
+    </Form.List>
+  );
+}
+
 function HeaderCustomGroups({
   tcpFieldName, form, absoluteSettingsPath,
 }: {
@@ -1031,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>;

+ 5 - 3
frontend/src/schemas/protocols/stream/xhttp.ts

@@ -31,12 +31,14 @@ export const XHttpXmuxSchema = z.object({
 export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
 
 // Seed for freshly enabling XMUX on a config that had no xmux block:
-// mirrors xray-core v26.6.27's own anti-RKN maxConnections=6 fallback
-// rather than the concurrency strategy.
+// mirrors xray-core's own maxConnections fallback rather than the
+// concurrency strategy. v26.7.28 lowered that fallback from 6 to 3 for
+// anti-TSPU, so track it here to keep a fresh panel config matching what
+// the core would have picked on its own.
 export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
   ...XHttpXmuxSchema.parse({}),
   maxConcurrency: '',
-  maxConnections: 6,
+  maxConnections: 3,
 };
 
 // Predefined sessionIDTable names xray-core accepts as a shorthand for a

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

+ 45 - 1
frontend/src/test/finalmask.test.ts

@@ -1,7 +1,7 @@
 /// <reference types="vite/client" />
 import { describe, expect, it } from 'vitest';
 
-import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
+import { migrateXmcSettings, parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
 import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream';
 
 const fixtures = import.meta.glob<unknown>(
@@ -26,6 +26,50 @@ describe('FinalMaskStreamSettingsSchema fixtures', () => {
   }
 });
 
+describe('migrateXmcSettings', () => {
+  it('carries legacy usernames into profile stubs and drops the dead key', () => {
+    const { next, changed } = migrateXmcSettings({
+      hostname: 'mc.example.com',
+      usernames: ['Dream', 'Notch'],
+      password: 'pw',
+    });
+
+    expect(changed).toBe(true);
+    expect(next.usernames).toBeUndefined();
+    expect(next.hostname).toBe('mc.example.com');
+    expect(next.password).toBe('pw');
+    expect(next.profiles).toEqual([
+      { username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
+      { username: 'Notch', uuid: '', texturesValue: '', texturesSignature: '' },
+    ]);
+  });
+
+  it('gives a mask with neither key an empty profiles list', () => {
+    const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw' });
+
+    expect(changed).toBe(true);
+    expect(next.profiles).toEqual([]);
+  });
+
+  it('leaves an already migrated mask untouched', () => {
+    const profiles = [
+      { username: 'Notch', uuid: '069a79f4-44e9-4726-a5be-fca90e38aaf5', texturesValue: 'dmFsdWU=', texturesSignature: 'c2ln' },
+    ];
+    const { next, changed } = migrateXmcSettings({ hostname: '', password: 'pw', profiles });
+
+    expect(changed).toBe(false);
+    expect(next.profiles).toEqual(profiles);
+  });
+
+  it('discards blank legacy usernames rather than seeding unfixable stubs', () => {
+    const { next } = migrateXmcSettings({ usernames: ['Dream', '', '   '], password: 'pw' });
+
+    expect(next.profiles).toEqual([
+      { username: 'Dream', uuid: '', texturesValue: '', texturesSignature: '' },
+    ]);
+  });
+});
+
 describe('parseGeckoPacketSize', () => {
   it('accepts positive ordered packet size ranges', () => {
     expect(parseGeckoPacketSize('512-1200')).toEqual({ min: 512, max: 1200 });

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

+ 3 - 3
frontend/src/test/stream-wire-normalize.test.ts

@@ -158,8 +158,8 @@ describe('normalizeXhttpForWire stream-one', () => {
     expect(XHttpXmuxSchema.parse({}).maxConcurrency).toBe('16-32');
   });
 
-  it('XMUX_FRESH_DEFAULTS seeds the anti-RKN maxConnections=6 without a competing maxConcurrency', () => {
-    expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(6);
+  it('XMUX_FRESH_DEFAULTS seeds the core maxConnections fallback without a competing maxConcurrency', () => {
+    expect(XMUX_FRESH_DEFAULTS.maxConnections).toBe(3);
     expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
 
     const out = normalizeXhttpForWire({
@@ -170,7 +170,7 @@ describe('normalizeXhttpForWire stream-one', () => {
     }, 'outbound');
 
     const xmux = out.xmux as Record<string, unknown>;
-    expect(xmux.maxConnections).toBe(6);
+    expect(xmux.maxConnections).toBe(3);
     expect(xmux.maxConcurrency).toBe('');
   });
 });

+ 1 - 1
go.mod

@@ -21,7 +21,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.72.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c
+	github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.54.0
 	golang.org/x/sys v0.47.0

+ 2 - 2
go.sum

@@ -218,8 +218,8 @@ github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c h1:SbB1ez0bqZllbzaVj0PC+Vje3dRA8m/7jW1ussjDSgM=
-github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c/go.mod h1:Jts8yHqPCpvsdL5CW5xMd8H9d2fkg1cILeBNqEwRXNw=
+github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc h1:fkOkmgHWbF2Q8MdV9VxrsyxRz4OndcrUXUkh1ANBTg0=
+github.com/xtls/xray-core v1.260327.1-0.20260728075948-5ca6f4b7d4dc/go.mod h1:wukQoBGnQ6GaLTGuKwv8rCTgf80QxPj+6iznDZHQEWo=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=

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

+ 1 - 1
internal/web/job/node_heartbeat_job.go

@@ -63,7 +63,7 @@ func (j *NodeHeartbeatJob) Run() {
 	if !websocket.HasClients() {
 		return
 	}
-	updated, err := j.nodeService.GetNodeTree()
+	updated, err := j.nodeService.GetNodeTreeView()
 	if err != nil {
 		logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
 		return

+ 2 - 2
internal/web/service/client_inbound_apply.go

@@ -476,7 +476,7 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
 				}
 				cipher := ""
 				if oldInbound.Protocol == "shadowsocks" {
-					cipher = oldSettings["method"].(string)
+					cipher, _ = oldSettings["method"].(string)
 				}
 				err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
 					"email":        client.Email,
@@ -858,7 +858,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 				if clients[0].Enable {
 					cipher := ""
 					if oldInbound.Protocol == "shadowsocks" {
-						cipher = oldSettings["method"].(string)
+						cipher, _ = oldSettings["method"].(string)
 					}
 					err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
 						"email":        clients[0].Email,

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

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

@@ -8,10 +8,13 @@ import (
 	"errors"
 	"fmt"
 	"net"
+	"regexp"
 	"sort"
 	"strings"
 	"time"
 
+	"github.com/google/uuid"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -592,6 +595,188 @@ func validateFinalMaskRealityCombo(streamSettings string) error {
 	return common.NewError("Finalmask is not supported with REALITY security — it crashes Xray-core on the first connection (see XTLS/Xray-core#6453). Remove the finalmask configuration or switch security to tls/none.")
 }
 
+var xmcProfileUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`)
+
+// xmcMaskProfilesComplete reports whether an xmc finalmask carries the signed
+// Minecraft session profiles xray-core has required since v26.7.28 (#6487).
+// The core replaced the old `usernames` string list with `profiles` objects
+// and removed the "default to Dream when empty" fallback, so a mask still on
+// the legacy shape — or one whose profiles are incomplete — now fails
+// conf.XMC.Build() and takes the entire config down with it rather than
+// degrading that one inbound.
+//
+// The texture fields are a signed blob only Mojang's session server can issue
+// (resolve the UUID by username, then fetch the profile with unsigned=false),
+// so the panel cannot synthesize a valid profile from a legacy username; an
+// incomplete mask can only be reported or dropped.
+func xmcMaskProfilesComplete(mask map[string]any) bool {
+	settings, ok := mask["settings"].(map[string]any)
+	if !ok {
+		return false
+	}
+	profiles, _ := settings["profiles"].([]any)
+	if len(profiles) == 0 {
+		return false
+	}
+	for _, entry := range profiles {
+		profile, ok := entry.(map[string]any)
+		if !ok {
+			return false
+		}
+		username, _ := profile["username"].(string)
+		if !xmcProfileUsernamePattern.MatchString(username) {
+			return false
+		}
+		id, _ := profile["uuid"].(string)
+		if _, err := uuid.Parse(id); err != nil {
+			return false
+		}
+		if value, _ := profile["texturesValue"].(string); value == "" {
+			return false
+		}
+		if signature, _ := profile["texturesSignature"].(string); signature == "" {
+			return false
+		}
+	}
+	return true
+}
+
+// isIncompleteXmcMask reports whether a finalmask.tcp entry is an xmc mask
+// xray-core would refuse to build.
+func isIncompleteXmcMask(entry any) bool {
+	mask, ok := entry.(map[string]any)
+	if !ok {
+		return false
+	}
+	if maskType, _ := mask["type"].(string); maskType != "xmc" {
+		return false
+	}
+	return !xmcMaskProfilesComplete(mask)
+}
+
+// incompleteXmcMaskCount counts the stream's xmc finalmask entries that
+// xray-core would refuse to build.
+func incompleteXmcMaskCount(stream map[string]any) int {
+	finalmask, ok := stream["finalmask"].(map[string]any)
+	if !ok {
+		return 0
+	}
+	tcp, _ := finalmask["tcp"].([]any)
+	count := 0
+	for _, entry := range tcp {
+		if isIncompleteXmcMask(entry) {
+			count++
+		}
+	}
+	return count
+}
+
+// stripIncompleteXmcMasks removes every xmc finalmask entry xray-core would
+// refuse to build, returning how many were dropped, and clears the finalmask
+// object once nothing is left in it.
+//
+// AddInbound and UpdateInbound reject an incomplete mask at save time, but a
+// row that never went through those paths — an upgrade from a panel predating
+// v26.7.28, node sync, a restored backup, a direct DB edit — would otherwise
+// fail the whole config build and keep every other inbound offline too.
+// Dropping only the offending mask degrades that one inbound instead, which
+// the accompanying warning tells the admin to reconfigure.
+func stripIncompleteXmcMasks(stream map[string]any) int {
+	finalmask, ok := stream["finalmask"].(map[string]any)
+	if !ok {
+		return 0
+	}
+	tcp, _ := finalmask["tcp"].([]any)
+	if len(tcp) == 0 {
+		return 0
+	}
+	kept := make([]any, 0, len(tcp))
+	dropped := 0
+	for _, entry := range tcp {
+		if isIncompleteXmcMask(entry) {
+			dropped++
+			continue
+		}
+		kept = append(kept, entry)
+	}
+	if dropped == 0 {
+		return 0
+	}
+	if len(kept) == 0 {
+		delete(finalmask, "tcp")
+	} else {
+		finalmask["tcp"] = kept
+	}
+	if len(finalmask) == 0 {
+		delete(stream, "finalmask")
+	}
+	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
+// serving without the obfuscation they configured).
+func validateFinalMaskXmcProfiles(streamSettings string) error {
+	if streamSettings == "" {
+		return nil
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+		return nil
+	}
+	if incompleteXmcMaskCount(stream) == 0 {
+		return nil
+	}
+	return common.NewError("XMC finalmask requires at least one complete Minecraft profile — each needs a username (3-16 of A-Z a-z 0-9 _), a UUID, and both texture fields from Mojang's session server (XTLS/Xray-core#6487). Complete the profiles or remove the XMC mask.")
+}
+
 // normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is
 // always valid before the row is persisted, and drops the vestigial inbound-level
 // secret and adTag: MTProto is multi-client, so mtg and every share link read
@@ -725,6 +910,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
 		return inbound, false, err
 	}
+	if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
+		return inbound, false, err
+	}
 	s.normalizeMtprotoSecret(inbound)
 	if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil {
 		return inbound, false, err
@@ -1148,6 +1336,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 	if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil {
 		return inbound, false, err
 	}
+	if err := validateFinalMaskXmcProfiles(inbound.StreamSettings); err != nil {
+		return inbound, false, err
+	}
 	s.normalizeMtprotoSecret(inbound)
 	inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
 

+ 108 - 0
internal/web/service/inbound_autorenew_shadowsocks_test.go

@@ -0,0 +1,108 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// TestAPIUserFromClientCarriesShadowsocksCipher pins what the renew path must
+// hand the runtime API. A shadowsocks client object holds no cipher of its own,
+// and xray's legacy and 2022 inbounds take different account types that they
+// cast without checking — an account built for the wrong one panics the core.
+func TestAPIUserFromClientCarriesShadowsocksCipher(t *testing.T) {
+	client := map[string]any{"email": "a@x", "password": "pw", "method": "aes-256-gcm"}
+
+	user := apiUserFromClient(client, "aes-256-gcm")
+	if got := user["cipher"]; got != "aes-256-gcm" {
+		t.Fatalf("cipher = %v, want the inbound method", got)
+	}
+	if _, polluted := client["cipher"]; polluted {
+		t.Fatal("the stored client object was mutated; the API-only cipher would be persisted into the inbound settings")
+	}
+
+	user["email"] = "changed@x"
+	if client["email"] != "a@x" {
+		t.Fatal("the API user shares storage with the stored client object")
+	}
+}
+
+func TestAPIUserFromClientWithoutCipher(t *testing.T) {
+	client := map[string]any{"email": "a@x", "id": "11111111-1111-1111-1111-111111111111"}
+	user := apiUserFromClient(client, "")
+	if _, ok := user["cipher"]; ok {
+		t.Fatal("a non-shadowsocks client must not gain a cipher key")
+	}
+	if user["id"] != client["id"] {
+		t.Fatalf("id = %v, want it copied from the client", user["id"])
+	}
+}
+
+// TestAutoRenewShadowsocksKeepsSettingsClean renews a shadowsocks client and
+// checks the inbound settings the panel writes back: the cipher the API needs
+// must not leak into a stored client object, where it would end up in the
+// generated xray config as a per-user key.
+func TestAutoRenewShadowsocksKeepsSettingsClean(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	past := time.Now().Add(-48 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "ss@x", Password: "pw", Enable: false, Reset: 30, ExpiryTime: past},
+	}
+
+	settings := map[string]any{
+		"method":  "aes-256-gcm",
+		"network": "tcp,udp",
+		"clients": []map[string]any{
+			{"email": "ss@x", "password": "pw", "method": "aes-256-gcm", "enable": false, "expiryTime": past, "reset": 30},
+		},
+	}
+	raw, err := json.MarshalIndent(settings, "", "  ")
+	if err != nil {
+		t.Fatal(err)
+	}
+	ib := mkInbound(t, 30011, model.Shadowsocks, string(raw))
+	if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	if err := db.Create(&[]xray.ClientTraffic{
+		{InboundId: ib.Id, Email: "ss@x", Enable: false, Up: 10, Down: 20, Reset: 30, ExpiryTime: past},
+	}).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	if _, count, err := svc.autoRenewClients(db); err != nil {
+		t.Fatalf("autoRenewClients: %v", err)
+	} else if count != 1 {
+		t.Fatalf("renewed count = %d, want 1", count)
+	}
+
+	var stored model.Inbound
+	if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil {
+		t.Fatalf("read inbound: %v", err)
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(stored.Settings), &parsed); err != nil {
+		t.Fatalf("unmarshal stored settings: %v", err)
+	}
+	storedClients, _ := parsed["clients"].([]any)
+	if len(storedClients) != 1 {
+		t.Fatalf("stored clients = %d, want 1", len(storedClients))
+	}
+	client, _ := storedClients[0].(map[string]any)
+	if _, polluted := client["cipher"]; polluted {
+		t.Fatalf("the renewed client was persisted with an API-only cipher key: %+v", client)
+	}
+	if client["method"] != "aes-256-gcm" {
+		t.Fatalf("the client's method was lost: %+v", client)
+	}
+	if enabled, _ := client["enable"].(bool); !enabled {
+		t.Fatalf("the renewed client was not re-enabled: %+v", client)
+	}
+}

+ 204 - 0
internal/web/service/inbound_finalmask_xmc_test.go

@@ -0,0 +1,204 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/xtls/xray-core/infra/conf"
+)
+
+const completeXmcProfile = `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`
+
+func TestValidateFinalMaskXmcProfiles(t *testing.T) {
+	tests := []struct {
+		name           string
+		streamSettings string
+		wantErr        bool
+	}{
+		{
+			name:           "empty streamSettings",
+			streamSettings: "",
+			wantErr:        false,
+		},
+		{
+			name:           "no finalmask",
+			streamSettings: `{"network":"tcp","security":"none"}`,
+			wantErr:        false,
+		},
+		{
+			name:           "non-xmc mask is untouched",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
+			wantErr:        false,
+		},
+		{
+			name:           "xmc with a complete profile",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
+			wantErr:        false,
+		},
+		{
+			name:           "legacy usernames shape without profiles",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"hostname":"mc.example.com","password":"pw","usernames":["Dream"]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "xmc with an empty profiles array",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "profile missing the textures signature",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":""}]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "profile with an unparseable uuid",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"Notch","uuid":"not-a-uuid","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "profile with an out-of-range username",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[{"username":"ab","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}]}}]}}`,
+			wantErr:        true,
+		},
+		{
+			name:           "one complete and one incomplete profile",
+			streamSettings: `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `,{"username":"Herobrine","uuid":"","texturesValue":"","texturesSignature":""}]}}]}}`,
+			wantErr:        true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateFinalMaskXmcProfiles(tt.streamSettings)
+			if (err != nil) != tt.wantErr {
+				t.Errorf("validateFinalMaskXmcProfiles(%q) error = %v, wantErr %v", tt.streamSettings, err, tt.wantErr)
+			}
+		})
+	}
+}
+
+// TestXmcMaskProfilesCompleteMatchesCoreValidation pins the panel's predicate
+// to xray-core's own loader instead of a restatement of it: every profile the
+// panel accepts must build, and every one it rejects must fail to build. A
+// future core release that tightens or relaxes the rules fails here rather
+// than silently producing configs the core refuses to start on.
+func TestXmcMaskProfilesCompleteMatchesCoreValidation(t *testing.T) {
+	profiles := []struct {
+		name string
+		raw  string
+	}{
+		{name: "complete", raw: completeXmcProfile},
+		{name: "undashed uuid", raw: `{"username":"Notch","uuid":"069a79f444e94726a5befca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "username at the 16 char limit", raw: `{"username":"Abcdefghijklmnop","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "username over the limit", raw: `{"username":"Abcdefghijklmnopq","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "username with a hyphen", raw: `{"username":"No-tch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "empty uuid", raw: `{"username":"Notch","uuid":"","texturesValue":"dmFsdWU=","texturesSignature":"c2ln"}`},
+		{name: "missing textures value", raw: `{"username":"Notch","uuid":"069a79f4-44e9-4726-a5be-fca90e38aaf5","texturesValue":"","texturesSignature":"c2ln"}`},
+	}
+
+	for _, tt := range profiles {
+		t.Run(tt.name, func(t *testing.T) {
+			var coreProfile conf.XMCProfile
+			if err := json.Unmarshal([]byte(tt.raw), &coreProfile); err != nil {
+				t.Fatalf("unmarshal into conf.XMCProfile: %v", err)
+			}
+			_, coreErr := coreProfile.Build()
+
+			mask := map[string]any{"type": "xmc"}
+			var settings map[string]any
+			if err := json.Unmarshal([]byte(`{"profiles":[`+tt.raw+`]}`), &settings); err != nil {
+				t.Fatalf("unmarshal settings: %v", err)
+			}
+			mask["settings"] = settings
+
+			panelAccepts := xmcMaskProfilesComplete(mask)
+			coreAccepts := coreErr == nil
+			if panelAccepts != coreAccepts {
+				t.Errorf("xmcMaskProfilesComplete = %v, but conf.XMCProfile.Build() accepts = %v (err %v)", panelAccepts, coreAccepts, coreErr)
+			}
+		})
+	}
+}
+
+// TestXmcEmptyProfilesRejectedByCore covers the rule that lives on XMC rather
+// than XMCProfile: v26.7.28 dropped the "default to Dream" fallback, so a mask
+// with no profiles at all is now a build failure.
+func TestXmcEmptyProfilesRejectedByCore(t *testing.T) {
+	var core conf.XMC
+	if err := json.Unmarshal([]byte(`{"hostname":"mc.example.com","password":"pw","profiles":[]}`), &core); err != nil {
+		t.Fatalf("unmarshal into conf.XMC: %v", err)
+	}
+	if _, err := core.Build(); err == nil {
+		t.Fatal("conf.XMC.Build() accepted an empty profiles list; the panel's strip/validate pair is no longer needed")
+	}
+}
+
+func TestStripIncompleteXmcMasks(t *testing.T) {
+	tests := []struct {
+		name        string
+		stream      string
+		wantDropped int
+		wantStream  string
+	}{
+		{
+			name:        "legacy usernames mask is dropped and finalmask removed",
+			stream:      `{"network":"tcp","finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"],"password":"pw"}}]}}`,
+			wantDropped: 1,
+			wantStream:  `{"network":"tcp"}`,
+		},
+		{
+			name:        "complete mask is kept",
+			stream:      `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
+			wantDropped: 0,
+			wantStream:  `{"finalmask":{"tcp":[{"type":"xmc","settings":{"password":"pw","profiles":[` + completeXmcProfile + `]}}]}}`,
+		},
+		{
+			name:        "sibling masks survive the drop",
+			stream:      `{"finalmask":{"tcp":[{"type":"xmc","settings":{"usernames":["Dream"]}},{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
+			wantDropped: 1,
+			wantStream:  `{"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`,
+		},
+		{
+			name:        "udp masks are preserved when tcp empties out",
+			stream:      `{"finalmask":{"tcp":[{"type":"xmc","settings":{}}],"udp":[{"type":"salamander"}]}}`,
+			wantDropped: 1,
+			wantStream:  `{"finalmask":{"udp":[{"type":"salamander"}]}}`,
+		},
+		{
+			name:        "stream without finalmask is untouched",
+			stream:      `{"network":"tcp","security":"tls"}`,
+			wantDropped: 0,
+			wantStream:  `{"network":"tcp","security":"tls"}`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			var stream map[string]any
+			if err := json.Unmarshal([]byte(tt.stream), &stream); err != nil {
+				t.Fatalf("unmarshal stream: %v", err)
+			}
+
+			dropped := stripIncompleteXmcMasks(stream)
+			if dropped != tt.wantDropped {
+				t.Errorf("stripIncompleteXmcMasks dropped = %d, want %d", dropped, tt.wantDropped)
+			}
+
+			var want map[string]any
+			if err := json.Unmarshal([]byte(tt.wantStream), &want); err != nil {
+				t.Fatalf("unmarshal wantStream: %v", err)
+			}
+			got, err := json.Marshal(stream)
+			if err != nil {
+				t.Fatalf("marshal stream: %v", err)
+			}
+			wantJSON, err := json.Marshal(want)
+			if err != nil {
+				t.Fatalf("marshal want: %v", err)
+			}
+			if string(got) != string(wantJSON) {
+				t.Errorf("stream after strip = %s, want %s", got, wantJSON)
+			}
+		})
+	}
+}

+ 23 - 2
internal/web/service/inbound_traffic.go

@@ -287,6 +287,23 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
 	return dbClientTraffics, newExpiryByEmail, nil
 }
 
+// apiUserFromClient prepares a stored client object for the runtime AddUser
+// call. The copy matters twice over: the stored object keeps being mutated and
+// marshalled back into the inbound's settings, which must not gain an API-only
+// key, and shadowsocks clients carry no cipher of their own — it lives on the
+// inbound, and without it the API cannot tell which of xray's two shadowsocks
+// account types the running inbound expects.
+func apiUserFromClient(client map[string]any, cipher string) map[string]any {
+	user := maps.Clone(client)
+	if user == nil {
+		user = map[string]any{}
+	}
+	if cipher != "" {
+		user["cipher"] = cipher
+	}
+	return user
+}
+
 func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	// check for time expired
 	var traffics []*xray.ClientTraffic
@@ -367,6 +384,10 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 		if len(clients) == 0 {
 			continue
 		}
+		cipher := ""
+		if inbounds[inbound_index].Protocol == model.Shadowsocks {
+			cipher, _ = settings["method"].(string)
+		}
 		for client_index := range clients {
 			c := clients[client_index].(map[string]any)
 			email, _ := c["email"].(string)
@@ -393,7 +414,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 					}{
 						protocol: string(inbounds[inbound_index].Protocol),
 						tag:      inbounds[inbound_index].Tag,
-						client:   c,
+						client:   apiUserFromClient(c, cipher),
 					})
 			}
 			clients[client_index] = any(c)
@@ -603,7 +624,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 					if err != nil {
 						return false, err
 					}
-					cipher = oldSettings["method"].(string)
+					cipher, _ = oldSettings["method"].(string)
 				}
 				err1 := rt.AddUser(context.Background(), inbound, map[string]any{
 					"email":    client.Email,

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

@@ -281,6 +281,12 @@ 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)
+			}
+
 			// xray-core v26.6.22 (#6258) renamed the XHTTP session keys and
 			// kept no fallback. Lift legacy sessionPlacement/sessionKey onto the
 			// new names here so inbounds stored before the rename keep working

+ 0 - 3
internal/web/websocket/notifier.go

@@ -63,9 +63,6 @@ func BroadcastInbounds(inbounds any) {
 	}
 }
 
-// BroadcastNodes broadcasts the fresh node list to all connected clients.
-// Pushed by NodeHeartbeatJob at the end of each 10s tick so the Nodes page
-// reflects status / latency / cpu / mem updates without polling.
 func BroadcastNodes(nodes any) {
 	if hub := GetHub(); hub != nil {
 		hub.Broadcast(MessageTypeNodes, nodes)

+ 98 - 25
internal/xray/api.go

@@ -345,6 +345,10 @@ func (x *XrayAPI) TestRoute(req RouteTestRequest) (*RouteTestResult, error) {
 		return nil, common.NewError("xray RoutingServiceClient is not initialized")
 	}
 
+	if req.Port < 0 || req.Port > math.MaxUint16 {
+		return nil, common.NewErrorf("invalid port: %d", req.Port)
+	}
+
 	network := xnet.Network_TCP
 	if strings.EqualFold(req.Network, "udp") {
 		network = xnet.Network_UDP
@@ -461,11 +465,71 @@ func collectStringSlice(value any) []string {
 	}
 }
 
+// legacyShadowsocksAccountType is the type URL serial.ToTypedMessage stamps on
+// a pre-2022 shadowsocks account, which identifies the one inbound whose user
+// list tolerates duplicate emails.
+const legacyShadowsocksAccountType = "xray.proxy.shadowsocks.Account"
+
+// shadowsocks2022Ciphers are the methods that select xray's shadowsocks-2022
+// inbound (sing's shadowaead_2022 list). They take a different account type
+// than the legacy AEAD ciphers, and the running inbound casts the account it
+// receives without checking, so a wrong guess takes the whole core down.
+var shadowsocks2022Ciphers = map[string]struct{}{
+	"2022-blake3-aes-128-gcm":       {},
+	"2022-blake3-aes-256-gcm":       {},
+	"2022-blake3-chacha20-poly1305": {},
+}
+
+// shadowsocksCipherName resolves the cipher a shadowsocks user's account must
+// be built for. Panel-built user maps carry it under "cipher"; client objects
+// taken verbatim from an inbound's settings carry the inbound's method under
+// "method" instead (HealShadowsocksClientMethods writes it onto every
+// legacy-cipher client).
+func shadowsocksCipherName(user map[string]any) (string, error) {
+	cipher, err := getOptionalUserString(user, "cipher")
+	if err != nil {
+		return "", err
+	}
+	if cipher != "" {
+		return cipher, nil
+	}
+	return getOptionalUserString(user, "method")
+}
+
+// shadowsocksCipherType mirrors xray-core's infra/conf cipherFromString,
+// aliases and case-insensitivity included, so the account the panel builds for
+// a live user matches the one the core built for that inbound from its config.
+func shadowsocksCipherType(cipher string) shadowsocks.CipherType {
+	switch strings.ToLower(cipher) {
+	case "aes-128-gcm", "aead_aes_128_gcm":
+		return shadowsocks.CipherType_AES_128_GCM
+	case "aes-256-gcm", "aead_aes_256_gcm":
+		return shadowsocks.CipherType_AES_256_GCM
+	case "chacha20-poly1305", "aead_chacha20_poly1305", "chacha20-ietf-poly1305":
+		return shadowsocks.CipherType_CHACHA20_POLY1305
+	case "xchacha20-poly1305", "aead_xchacha20_poly1305", "xchacha20-ietf-poly1305":
+		return shadowsocks.CipherType_XCHACHA20_POLY1305
+	default:
+		return shadowsocks.CipherType_UNKNOWN
+	}
+}
+
+// isShadowsocks2022Cipher reports whether the method selects the
+// shadowsocks-2022 inbound rather than the legacy AEAD one.
+func isShadowsocks2022Cipher(cipher string) bool {
+	_, ok := shadowsocks2022Ciphers[strings.ToLower(cipher)]
+	return ok
+}
+
 // buildUserAccount constructs the typed xray account for a user of the given
 // protocol. It returns (nil, nil) for protocols that cannot be altered live so
 // callers skip the AlterInbound call. WireGuard keys must be converted to the
 // hex form xray's wireguard proxy expects (its ParseKey uses hex.DecodeString),
 // unlike the file-config path which accepts base64 and converts internally.
+// Shadowsocks is resolved strictly from the inbound's cipher: the legacy and
+// 2022 inbounds take different account types and cast whatever they receive
+// without checking, so an unrecognized cipher is an error rather than a guess
+// that would panic the core and kill every connection on the server.
 func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMessage, error) {
 	switch protocolName {
 	case "vmess":
@@ -523,7 +587,7 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
 			Password: password,
 		}), nil
 	case "shadowsocks":
-		cipher, err := getOptionalUserString(user, "cipher")
+		cipher, err := shadowsocksCipherName(user)
 		if err != nil {
 			return nil, err
 		}
@@ -533,28 +597,19 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
 			return nil, err
 		}
 
-		var ssCipherType shadowsocks.CipherType
-		switch cipher {
-		case "aes-128-gcm":
-			ssCipherType = shadowsocks.CipherType_AES_128_GCM
-		case "aes-256-gcm":
-			ssCipherType = shadowsocks.CipherType_AES_256_GCM
-		case "chacha20-poly1305", "chacha20-ietf-poly1305":
-			ssCipherType = shadowsocks.CipherType_CHACHA20_POLY1305
-		case "xchacha20-poly1305", "xchacha20-ietf-poly1305":
-			ssCipherType = shadowsocks.CipherType_XCHACHA20_POLY1305
-		default:
-			ssCipherType = shadowsocks.CipherType_UNKNOWN
-		}
-
-		if ssCipherType != shadowsocks.CipherType_UNKNOWN {
-			return serial.ToTypedMessage(&shadowsocks.Account{
-				Password:   password,
-				CipherType: ssCipherType,
+		if isShadowsocks2022Cipher(cipher) {
+			return serial.ToTypedMessage(&shadowsocks_2022.Account{
+				Key: password,
 			}), nil
 		}
-		return serial.ToTypedMessage(&shadowsocks_2022.Account{
-			Key: password,
+
+		ssCipherType := shadowsocksCipherType(cipher)
+		if ssCipherType == shadowsocks.CipherType_UNKNOWN {
+			return nil, common.NewErrorf("shadowsocks: unknown cipher %q, cannot build an account for the running inbound", cipher)
+		}
+		return serial.ToTypedMessage(&shadowsocks.Account{
+			Password:   password,
+			CipherType: ssCipherType,
 		}), nil
 	case "hysteria":
 		auth, err := getRequiredUserString(user, "auth")
@@ -605,7 +660,11 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
 	}
 }
 
-// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
+// AddUser adds a user to an inbound in the Xray core using the specified
+// protocol and user data. On a legacy shadowsocks inbound the add first drops
+// any existing holder of the email: that is the one inbound whose validator
+// does not reject a duplicate email, and a later removal would then drop just
+// one of the two registrations, leaving a disabled client able to connect.
 func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
 	userEmail, err := getRequiredUserString(user, "email")
 	if err != nil {
@@ -625,6 +684,10 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an
 	}
 	client := *x.HandlerServiceClient
 
+	if account.Type == legacyShadowsocksAccountType {
+		_ = x.RemoveUser(inboundTag, userEmail)
+	}
+
 	ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout)
 	defer cancel()
 	_, err = client.AlterInbound(ctx, &command.AlterInboundRequest{
@@ -663,7 +726,13 @@ func (x *XrayAPI) RemoveUser(inboundTag, email string) error {
 	return nil
 }
 
-// GetTraffic queries traffic statistics from the Xray core, optionally resetting counters.
+// GetTraffic queries traffic statistics from the Xray core and reports what
+// accrued since the previous call; the counters themselves are never reset.
+// The first call of a process only records baselines, since it may be reading
+// counters that already hold traffic the panel cannot attribute. After that a
+// name the panel has not seen — xray creates a counter on a user's first use —
+// and a counter that moved backwards because the core restarted both count
+// from zero, so no client's traffic is dropped for a whole polling interval.
 func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
 	if x.grpcClient == nil {
 		return nil, nil, common.NewError("xray api is not initialized")
@@ -685,13 +754,17 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) {
 	tagTrafficMap := make(map[string]*Traffic)
 	emailTrafficMap := make(map[string]*ClientTraffic)
 
+	baselinePass := len(x.StatsLastValues) == 0
+
 	for _, stat := range resp.GetStat() {
 		lastValue, ok := x.StatsLastValues[stat.Name]
 		x.StatsLastValues[stat.Name] = stat.Value
-		if !ok || stat.Value < lastValue {
-			// skip first time of seen stat
+		if baselinePass {
 			continue
 		}
+		if !ok || stat.Value < lastValue {
+			lastValue = 0
+		}
 		value := stat.Value - lastValue
 		if matches := trafficRegex.FindStringSubmatch(stat.Name); len(matches) == 4 {
 			processTraffic(matches, value, tagTrafficMap)

+ 149 - 0
internal/xray/api_shadowsocks_test.go

@@ -0,0 +1,149 @@
+package xray
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/xtls/xray-core/proxy/shadowsocks"
+	"github.com/xtls/xray-core/proxy/shadowsocks_2022"
+	"google.golang.org/protobuf/proto"
+)
+
+// decodeSSAccount decodes the typed message buildUserAccount produced for a
+// shadowsocks user. The type URL is what the running inbound casts on, so the
+// test asserts on it directly.
+func decodeSSAccount(t *testing.T, user map[string]any) (typeURL string, legacy *shadowsocks.Account, modern *shadowsocks_2022.Account) {
+	t.Helper()
+	tm, err := buildUserAccount("shadowsocks", user)
+	if err != nil {
+		t.Fatalf("buildUserAccount: %v", err)
+	}
+	if tm == nil {
+		t.Fatal("buildUserAccount returned no account for shadowsocks")
+	}
+	typeURL = tm.Type
+	switch {
+	case strings.Contains(typeURL, "shadowsocks_2022"):
+		modern = new(shadowsocks_2022.Account)
+		if err := proto.Unmarshal(tm.Value, modern); err != nil {
+			t.Fatalf("unmarshal shadowsocks_2022 account: %v", err)
+		}
+	case strings.Contains(typeURL, "shadowsocks"):
+		legacy = new(shadowsocks.Account)
+		if err := proto.Unmarshal(tm.Value, legacy); err != nil {
+			t.Fatalf("unmarshal shadowsocks account: %v", err)
+		}
+	default:
+		t.Fatalf("unexpected account type %q", typeURL)
+	}
+	return typeURL, legacy, modern
+}
+
+func TestBuildUserAccountShadowsocksLegacyCiphers(t *testing.T) {
+	tests := []struct {
+		cipher string
+		want   shadowsocks.CipherType
+	}{
+		{"aes-128-gcm", shadowsocks.CipherType_AES_128_GCM},
+		{"aead_aes_128_gcm", shadowsocks.CipherType_AES_128_GCM},
+		{"aes-256-gcm", shadowsocks.CipherType_AES_256_GCM},
+		{"AES-256-GCM", shadowsocks.CipherType_AES_256_GCM},
+		{"aead_aes_256_gcm", shadowsocks.CipherType_AES_256_GCM},
+		{"chacha20-poly1305", shadowsocks.CipherType_CHACHA20_POLY1305},
+		{"chacha20-ietf-poly1305", shadowsocks.CipherType_CHACHA20_POLY1305},
+		{"aead_chacha20_poly1305", shadowsocks.CipherType_CHACHA20_POLY1305},
+		{"xchacha20-poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305},
+		{"xchacha20-ietf-poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305},
+		{"aead_xchacha20_poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305},
+	}
+	for _, tt := range tests {
+		t.Run(tt.cipher, func(t *testing.T) {
+			user := map[string]any{"email": "[email protected]", "password": "pw", "cipher": tt.cipher}
+			typeURL, legacy, modern := decodeSSAccount(t, user)
+			if modern != nil {
+				t.Fatalf("cipher %q built a shadowsocks-2022 account (%s); the legacy inbound casts to *shadowsocks.MemoryAccount and panics the core", tt.cipher, typeURL)
+			}
+			if legacy.CipherType != tt.want {
+				t.Fatalf("CipherType = %v, want %v", legacy.CipherType, tt.want)
+			}
+			if legacy.Password != "pw" {
+				t.Fatalf("Password = %q, want %q", legacy.Password, "pw")
+			}
+		})
+	}
+}
+
+func TestBuildUserAccountShadowsocks2022Ciphers(t *testing.T) {
+	for _, cipher := range []string{
+		"2022-blake3-aes-128-gcm",
+		"2022-blake3-aes-256-gcm",
+		"2022-blake3-chacha20-poly1305",
+	} {
+		t.Run(cipher, func(t *testing.T) {
+			user := map[string]any{"email": "[email protected]", "password": b64Key(7), "cipher": cipher}
+			typeURL, _, modern := decodeSSAccount(t, user)
+			if modern == nil {
+				t.Fatalf("cipher %q built a legacy account (%s), want shadowsocks-2022", cipher, typeURL)
+			}
+			if modern.Key != b64Key(7) {
+				t.Fatalf("Key = %q, want the client password", modern.Key)
+			}
+		})
+	}
+}
+
+// TestBuildUserAccountShadowsocksReadsMethodKey covers the client maps taken
+// verbatim from an inbound's settings (the auto-renew path): they carry the
+// inbound's cipher under "method", never "cipher".
+func TestBuildUserAccountShadowsocksReadsMethodKey(t *testing.T) {
+	user := map[string]any{"email": "[email protected]", "password": "pw", "method": "aes-256-gcm"}
+	_, legacy, modern := decodeSSAccount(t, user)
+	if modern != nil {
+		t.Fatal("a client carrying only \"method\" must still build a legacy account")
+	}
+	if legacy.CipherType != shadowsocks.CipherType_AES_256_GCM {
+		t.Fatalf("CipherType = %v, want AES_256_GCM", legacy.CipherType)
+	}
+}
+
+func TestBuildUserAccountShadowsocksCipherKeyWins(t *testing.T) {
+	user := map[string]any{
+		"email":    "[email protected]",
+		"password": b64Key(3),
+		"cipher":   "2022-blake3-aes-128-gcm",
+		"method":   "aes-256-gcm",
+	}
+	_, _, modern := decodeSSAccount(t, user)
+	if modern == nil {
+		t.Fatal("the explicit \"cipher\" must win over a stale \"method\"")
+	}
+}
+
+// TestBuildUserAccountShadowsocksUnknownCipherErrors is the regression for the
+// account-type guess that killed the core: an unrecognized cipher used to fall
+// through to a shadowsocks-2022 account, which xray's legacy inbound casts
+// without checking, panicking the whole process.
+func TestBuildUserAccountShadowsocksUnknownCipherErrors(t *testing.T) {
+	for _, cipher := range []string{"", "rc4-md5", "2022-blake3-future-gcm", "none"} {
+		t.Run("cipher="+cipher, func(t *testing.T) {
+			user := map[string]any{"email": "[email protected]", "password": "pw"}
+			if cipher != "" {
+				user["cipher"] = cipher
+			}
+			account, err := buildUserAccount("shadowsocks", user)
+			if err == nil {
+				t.Fatalf("cipher %q built account %v, want an error instead of an account-type guess", cipher, account)
+			}
+			if !strings.Contains(err.Error(), "unknown cipher") {
+				t.Fatalf("error = %q, want it to name the unknown cipher", err)
+			}
+		})
+	}
+}
+
+func TestBuildUserAccountShadowsocksMissingPassword(t *testing.T) {
+	user := map[string]any{"email": "[email protected]", "cipher": "aes-256-gcm"}
+	if _, err := buildUserAccount("shadowsocks", user); err == nil {
+		t.Fatal("expected an error for a shadowsocks user without a password")
+	}
+}

+ 192 - 0
internal/xray/api_traffic_test.go

@@ -0,0 +1,192 @@
+package xray
+
+import (
+	"context"
+	"net"
+	"testing"
+
+	statsService "github.com/xtls/xray-core/app/stats/command"
+	"google.golang.org/grpc"
+)
+
+// fakeStatsServer serves a scripted sequence of QueryStats responses, one per
+// call, so the delta bookkeeping in GetTraffic can be driven exactly.
+type fakeStatsServer struct {
+	statsService.UnimplementedStatsServiceServer
+	rounds [][]*statsService.Stat
+	calls  int
+}
+
+func (f *fakeStatsServer) QueryStats(context.Context, *statsService.QueryStatsRequest) (*statsService.QueryStatsResponse, error) {
+	round := f.calls
+	f.calls++
+	if round >= len(f.rounds) {
+		round = len(f.rounds) - 1
+	}
+	return &statsService.QueryStatsResponse{Stat: f.rounds[round]}, nil
+}
+
+func stat(name string, value int64) *statsService.Stat {
+	return &statsService.Stat{Name: name, Value: value}
+}
+
+// startFakeStats runs the scripted stats service and returns an XrayAPI wired
+// to it.
+func startFakeStats(t *testing.T, rounds [][]*statsService.Stat) *XrayAPI {
+	t.Helper()
+	lis, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	srv := grpc.NewServer()
+	statsService.RegisterStatsServiceServer(srv, &fakeStatsServer{rounds: rounds})
+	go func() { _ = srv.Serve(lis) }()
+	t.Cleanup(srv.Stop)
+
+	api := &XrayAPI{}
+	if err := api.Init(lis.Addr().(*net.TCPAddr).Port); err != nil {
+		t.Fatalf("api init: %v", err)
+	}
+	t.Cleanup(api.Close)
+	return api
+}
+
+func clientTrafficByEmail(t *testing.T, traffics []*ClientTraffic) map[string]*ClientTraffic {
+	t.Helper()
+	byEmail := make(map[string]*ClientTraffic, len(traffics))
+	for _, ct := range traffics {
+		byEmail[ct.Email] = ct
+	}
+	return byEmail
+}
+
+// TestGetTrafficFirstPollIsBaselineOnly pins the one case the skip protects:
+// the panel may attach to counters that already hold traffic it cannot
+// attribute, so the first poll of a client only records baselines.
+func TestGetTrafficFirstPollIsBaselineOnly(t *testing.T) {
+	api := startFakeStats(t, [][]*statsService.Stat{
+		{stat("user>>>alice>>>traffic>>>uplink", 5000)},
+	})
+
+	_, clients, err := api.GetTraffic()
+	if err != nil {
+		t.Fatalf("GetTraffic: %v", err)
+	}
+	if len(clients) != 0 {
+		t.Fatalf("first poll reported %+v, want no traffic (baseline only)", clients[0])
+	}
+	if got := api.StatsLastValues["user>>>alice>>>traffic>>>uplink"]; got != 5000 {
+		t.Fatalf("baseline = %d, want 5000", got)
+	}
+}
+
+// TestGetTrafficCountsNewStatFromZero is the regression for a new client's
+// traffic being dropped: xray creates a counter on first use, so a name that
+// appears after the baseline poll starts at zero and every byte in it is new.
+func TestGetTrafficCountsNewStatFromZero(t *testing.T) {
+	api := startFakeStats(t, [][]*statsService.Stat{
+		{stat("user>>>alice>>>traffic>>>uplink", 100)},
+		{
+			stat("user>>>alice>>>traffic>>>uplink", 180),
+			stat("user>>>bob>>>traffic>>>uplink", 4096),
+			stat("user>>>bob>>>traffic>>>downlink", 8192),
+		},
+	})
+
+	if _, _, err := api.GetTraffic(); err != nil {
+		t.Fatalf("GetTraffic (baseline): %v", err)
+	}
+	_, clients, err := api.GetTraffic()
+	if err != nil {
+		t.Fatalf("GetTraffic: %v", err)
+	}
+
+	byEmail := clientTrafficByEmail(t, clients)
+	bob, ok := byEmail["bob"]
+	if !ok {
+		t.Fatal("a client whose counter appeared after the baseline poll reported no traffic")
+	}
+	if bob.Up != 4096 || bob.Down != 8192 {
+		t.Fatalf("bob = up %d / down %d, want 4096 / 8192", bob.Up, bob.Down)
+	}
+	alice, ok := byEmail["alice"]
+	if !ok {
+		t.Fatal("alice reported no traffic")
+	}
+	if alice.Up != 80 {
+		t.Fatalf("alice up = %d, want the delta 80", alice.Up)
+	}
+}
+
+// TestGetTrafficCountsAfterCounterReset covers a core restart: the counters
+// start over at zero, so a value below the recorded baseline is all new
+// traffic rather than something to drop.
+func TestGetTrafficCountsAfterCounterReset(t *testing.T) {
+	api := startFakeStats(t, [][]*statsService.Stat{
+		{stat("user>>>alice>>>traffic>>>uplink", 100)},
+		{stat("user>>>alice>>>traffic>>>uplink", 900)},
+		{stat("user>>>alice>>>traffic>>>uplink", 250)},
+	})
+
+	if _, _, err := api.GetTraffic(); err != nil {
+		t.Fatalf("GetTraffic (baseline): %v", err)
+	}
+	if _, _, err := api.GetTraffic(); err != nil {
+		t.Fatalf("GetTraffic (delta): %v", err)
+	}
+	_, clients, err := api.GetTraffic()
+	if err != nil {
+		t.Fatalf("GetTraffic (after reset): %v", err)
+	}
+
+	alice, ok := clientTrafficByEmail(t, clients)["alice"]
+	if !ok {
+		t.Fatal("traffic after a counter reset was dropped entirely")
+	}
+	if alice.Up != 250 {
+		t.Fatalf("alice up = %d, want 250 counted from zero", alice.Up)
+	}
+}
+
+// TestGetTrafficSkipsAPIInboundAndPrunes checks the tag-level parsing: the api
+// inbound is the panel's own gRPC channel and is never a user-facing inbound,
+// and baselines for vanished stats are dropped once they outgrow the live set.
+func TestGetTrafficSkipsAPIInboundAndPrunes(t *testing.T) {
+	api := startFakeStats(t, [][]*statsService.Stat{
+		{
+			stat("inbound>>>api>>>traffic>>>uplink", 10),
+			stat("inbound>>>in-443>>>traffic>>>uplink", 10),
+			stat("inbound>>>gone-1>>>traffic>>>uplink", 10),
+			stat("inbound>>>gone-2>>>traffic>>>uplink", 10),
+			stat("inbound>>>gone-3>>>traffic>>>uplink", 10),
+			stat("inbound>>>gone-4>>>traffic>>>uplink", 10),
+			stat("inbound>>>gone-5>>>traffic>>>uplink", 10),
+		},
+		{
+			stat("inbound>>>api>>>traffic>>>uplink", 99),
+			stat("inbound>>>in-443>>>traffic>>>uplink", 60),
+			stat("inbound>>>in-443>>>traffic>>>downlink", 70),
+		},
+	})
+
+	if _, _, err := api.GetTraffic(); err != nil {
+		t.Fatalf("GetTraffic (baseline): %v", err)
+	}
+	tags, _, err := api.GetTraffic()
+	if err != nil {
+		t.Fatalf("GetTraffic: %v", err)
+	}
+
+	if len(tags) != 1 {
+		t.Fatalf("got %d tag traffics, want only the non-api inbound: %+v", len(tags), tags)
+	}
+	if tags[0].Tag != "in-443" || !tags[0].IsInbound || tags[0].IsOutbound {
+		t.Fatalf("tag traffic = %+v, want inbound in-443", tags[0])
+	}
+	if tags[0].Up != 50 || tags[0].Down != 70 {
+		t.Fatalf("in-443 = up %d / down %d, want 50 / 70", tags[0].Up, tags[0].Down)
+	}
+	if _, stale := api.StatsLastValues["inbound>>>gone-1>>>traffic>>>uplink"]; stale {
+		t.Fatal("baselines for stats that no longer exist were not pruned")
+	}
+}

+ 447 - 0
internal/xray/api_users_e2e_test.go

@@ -0,0 +1,447 @@
+package xray
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+// e2eCore is a real xray-core process plus a connected panel API client.
+type e2eCore struct {
+	api  *XrayAPI
+	cmd  *exec.Cmd
+	port int
+}
+
+// alive reports whether the core is still serving its API port. A gRPC call
+// that hands the core an account type its inbound does not expect takes the
+// whole process down, so every user probe checks this.
+func (c *e2eCore) alive() bool {
+	if c.cmd.ProcessState != nil {
+		return false
+	}
+	conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", c.port), time.Second)
+	if err != nil {
+		return false
+	}
+	conn.Close()
+	return true
+}
+
+// startE2ECore boots xray-core with the given inbounds plus the api inbound the
+// panel talks through, and returns a connected client. Skips unless
+// XRAY_E2E_BINARY points at an xray built from the version go.mod pins.
+func startE2ECore(t *testing.T, inbounds []any) *e2eCore {
+	t.Helper()
+	bin := os.Getenv("XRAY_E2E_BINARY")
+	if bin == "" {
+		t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
+	}
+
+	apiPort := freePort(t)
+	all := []any{
+		map[string]any{
+			"listen":   "127.0.0.1",
+			"port":     apiPort,
+			"protocol": "tunnel",
+			"settings": map[string]any{"rewriteAddress": "127.0.0.1"},
+			"tag":      "api",
+		},
+	}
+	all = append(all, inbounds...)
+
+	cfg := map[string]any{
+		"log": map[string]any{"loglevel": "warning"},
+		"api": map[string]any{
+			"services": []string{"HandlerService", "StatsService", "RoutingService"},
+			"tag":      "api",
+		},
+		"inbounds": all,
+		"outbounds": []any{
+			map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
+			map[string]any{"protocol": "blackhole", "settings": map[string]any{}, "tag": "blocked"},
+		},
+		"routing": map[string]any{
+			"domainStrategy": "AsIs",
+			"rules": []any{
+				map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
+			},
+		},
+		"policy": map[string]any{
+			"levels": map[string]any{
+				"0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true, "statsUserOnline": true},
+			},
+			"system": map[string]any{"statsInboundUplink": true, "statsInboundDownlink": true},
+		},
+		"stats": map[string]any{},
+	}
+	raw, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		t.Fatal(err)
+	}
+	cfgPath := filepath.Join(t.TempDir(), "config.json")
+	if err := os.WriteFile(cfgPath, raw, 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	cmd := exec.Command(bin, "-c", cfgPath)
+	cmd.Stdout = os.Stderr
+	cmd.Stderr = os.Stderr
+	if err := cmd.Start(); err != nil {
+		t.Fatalf("failed to start xray: %v", err)
+	}
+	t.Cleanup(func() {
+		_ = cmd.Process.Kill()
+		_, _ = cmd.Process.Wait()
+	})
+	waitForPort(t, apiPort)
+
+	api := &XrayAPI{}
+	if err := api.Init(apiPort); err != nil {
+		t.Fatalf("api init: %v", err)
+	}
+	t.Cleanup(api.Close)
+	return &e2eCore{api: api, cmd: cmd, port: apiPort}
+}
+
+// ssKey builds a base64 shadowsocks-2022 key of the given byte length.
+func ssKey(n int, seed byte) string {
+	raw := make([]byte, n)
+	for i := range raw {
+		raw[i] = seed + byte(i)
+	}
+	return base64.StdEncoding.EncodeToString(raw)
+}
+
+// panelUser mirrors the map shape the panel's client-apply paths hand to
+// AddUser: every field is always present, unused ones are empty.
+func panelUser(email string, fields map[string]any) map[string]any {
+	user := map[string]any{
+		"email":        email,
+		"id":           "",
+		"auth":         "",
+		"security":     "",
+		"flow":         "",
+		"password":     "",
+		"cipher":       "",
+		"publicKey":    "",
+		"allowedIPs":   nil,
+		"preSharedKey": "",
+		"keepAlive":    "",
+	}
+	for k, v := range fields {
+		user[k] = v
+	}
+	return user
+}
+
+// TestXrayAPI_E2E_Users runs the panel's add/remove user surface against a live
+// core for every protocol it builds accounts for, pinning the error texts
+// IsUserExistsErr and IsMissingHandlerErr match on.
+func TestXrayAPI_E2E_Users(t *testing.T) {
+	c := startE2ECore(t, []any{
+		map[string]any{
+			"listen": "127.0.0.1", "port": freePort(t), "protocol": "vmess", "tag": "vmess-in",
+			"settings": map[string]any{"clients": []any{
+				map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "seed-vmess"},
+			}},
+		},
+		map[string]any{
+			"listen": "127.0.0.1", "port": freePort(t), "protocol": "vless", "tag": "vless-in",
+			"settings": map[string]any{"clients": []any{
+				map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30812", "email": "seed-vless"},
+			}, "decryption": "none"},
+		},
+		map[string]any{
+			"listen": "127.0.0.1", "port": freePort(t), "protocol": "trojan", "tag": "trojan-in",
+			"settings": map[string]any{"clients": []any{
+				map[string]any{"password": "seed-pw", "email": "seed-trojan"},
+			}},
+		},
+		map[string]any{
+			"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
+			"settings": map[string]any{
+				"method": "aes-256-gcm", "network": "tcp,udp",
+				"clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
+			},
+		},
+		map[string]any{
+			"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in",
+			"settings": map[string]any{
+				"method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp",
+				"clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}},
+			},
+		},
+	})
+
+	tests := []struct {
+		name     string
+		protocol string
+		tag      string
+		user     map[string]any
+		// rejectsDuplicateEmail is false for the legacy shadowsocks inbound,
+		// whose validator does not dedupe emails at all.
+		rejectsDuplicateEmail bool
+	}{
+		{
+			name: "vmess", protocol: "vmess", tag: "vmess-in",
+			user:                  panelUser("e2e-vmess", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30821", "security": "auto"}),
+			rejectsDuplicateEmail: true,
+		},
+		{
+			name: "vless", protocol: "vless", tag: "vless-in",
+			user:                  panelUser("e2e-vless", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30822", "flow": "xtls-rprx-vision"}),
+			rejectsDuplicateEmail: true,
+		},
+		{
+			name: "trojan", protocol: "trojan", tag: "trojan-in",
+			user:                  panelUser("e2e-trojan", map[string]any{"password": "e2e-pw"}),
+			rejectsDuplicateEmail: true,
+		},
+		{
+			name: "shadowsocks legacy", protocol: "shadowsocks", tag: "ss-in",
+			user:                  panelUser("e2e-ss", map[string]any{"password": "e2e-pw", "cipher": "aes-256-gcm"}),
+			rejectsDuplicateEmail: false,
+		},
+		{
+			name: "shadowsocks 2022", protocol: "shadowsocks", tag: "ss2022-in",
+			user:                  panelUser("e2e-ss2022", map[string]any{"password": ssKey(32, 40), "cipher": "2022-blake3-aes-256-gcm"}),
+			rejectsDuplicateEmail: true,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			email := tt.user["email"].(string)
+			if err := c.api.AddUser(tt.protocol, tt.tag, tt.user); err != nil {
+				t.Fatalf("AddUser: %v", err)
+			}
+			if !c.alive() {
+				t.Fatal("core died on AddUser: the account type does not match the running inbound")
+			}
+
+			if tt.rejectsDuplicateEmail {
+				err := c.api.AddUser(tt.protocol, tt.tag, tt.user)
+				if err == nil {
+					t.Fatal("adding a user whose email is taken must fail")
+				}
+				if !IsUserExistsErr(err) {
+					t.Fatalf("duplicate-email error not matched by IsUserExistsErr: %q", err)
+				}
+			}
+
+			if err := c.api.RemoveUser(tt.tag, email); err != nil {
+				t.Fatalf("RemoveUser: %v", err)
+			}
+			err := c.api.RemoveUser(tt.tag, email)
+			if err == nil {
+				t.Fatal("the user is still registered after RemoveUser")
+			}
+			if !IsMissingHandlerErr(err) {
+				t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err)
+			}
+		})
+	}
+
+	t.Run("unknown inbound tag", func(t *testing.T) {
+		err := c.api.AddUser("vmess", "no-such-tag", panelUser("e2e-ghost", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30899"}))
+		if err == nil {
+			t.Fatal("AddUser on an unknown tag must fail")
+		}
+		if !IsMissingHandlerErr(err) {
+			t.Fatalf("unknown-tag error not matched by IsMissingHandlerErr: %q", err)
+		}
+	})
+}
+
+// TestXrayAPI_E2E_ShadowsocksAccountTypeGuess is the regression for the crash
+// that took the whole core down: a shadowsocks user whose cipher the panel
+// could not resolve used to be sent as a shadowsocks-2022 account, which the
+// legacy inbound casts without checking. Every case here must leave the core
+// running.
+func TestXrayAPI_E2E_ShadowsocksAccountTypeGuess(t *testing.T) {
+	c := startE2ECore(t, []any{
+		map[string]any{
+			"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
+			"settings": map[string]any{
+				"method": "aes-256-gcm", "network": "tcp,udp",
+				"clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
+			},
+		},
+		map[string]any{
+			"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in",
+			"settings": map[string]any{
+				"method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp",
+				"clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}},
+			},
+		},
+	})
+
+	// The auto-renew path hands over the client object straight out of the
+	// inbound's settings, which carries the cipher under "method".
+	t.Run("client object from settings", func(t *testing.T) {
+		user := map[string]any{"email": "renew-ss", "password": "pw", "method": "aes-256-gcm", "enable": true}
+		if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
+			t.Fatalf("AddUser with the settings-shaped client: %v", err)
+		}
+		if !c.alive() {
+			t.Fatal("core died: a legacy shadowsocks client was sent as a 2022 account")
+		}
+		if err := c.api.RemoveUser("ss-in", "renew-ss"); err != nil {
+			t.Fatalf("RemoveUser: %v", err)
+		}
+	})
+
+	t.Run("cipher missing entirely", func(t *testing.T) {
+		err := c.api.AddUser("shadowsocks", "ss-in", map[string]any{"email": "nocipher", "password": "pw"})
+		if err == nil {
+			t.Fatal("a shadowsocks user with no resolvable cipher must be refused, not guessed")
+		}
+		if !c.alive() {
+			t.Fatal("core died on a shadowsocks user with no cipher")
+		}
+	})
+
+	t.Run("legacy cipher against a 2022 inbound", func(t *testing.T) {
+		// The reverse mismatch is only reachable when the panel's view of the
+		// inbound has drifted from the running one; the account type is still
+		// the one the cipher names, so the panel never guesses here.
+		user := map[string]any{"email": "drift", "password": ssKey(32, 50), "cipher": "2022-blake3-aes-256-gcm"}
+		if err := c.api.AddUser("shadowsocks", "ss2022-in", user); err != nil {
+			t.Fatalf("AddUser: %v", err)
+		}
+		if !c.alive() {
+			t.Fatal("core died adding a 2022 user to a 2022 inbound")
+		}
+		if err := c.api.RemoveUser("ss2022-in", "drift"); err != nil {
+			t.Fatalf("RemoveUser: %v", err)
+		}
+	})
+}
+
+// TestXrayAPI_E2E_ShadowsocksAddIsIdempotent covers the legacy shadowsocks
+// inbound accepting a second user under an email it already holds: one removal
+// then left the client connectable. Adding twice must leave exactly one
+// registration, so a single removal fully revokes the client.
+func TestXrayAPI_E2E_ShadowsocksAddIsIdempotent(t *testing.T) {
+	c := startE2ECore(t, []any{
+		map[string]any{
+			"listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
+			"settings": map[string]any{
+				"method": "aes-256-gcm", "network": "tcp,udp",
+				"clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
+			},
+		},
+	})
+
+	user := map[string]any{"email": "dup-ss", "password": "pw", "cipher": "aes-256-gcm"}
+	for i := range 2 {
+		if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
+			t.Fatalf("AddUser #%d: %v", i+1, err)
+		}
+	}
+
+	if err := c.api.RemoveUser("ss-in", "dup-ss"); err != nil {
+		t.Fatalf("RemoveUser: %v", err)
+	}
+	err := c.api.RemoveUser("ss-in", "dup-ss")
+	if err == nil {
+		t.Fatal("a second registration survived removal: a disabled client would keep connecting")
+	}
+	if !IsMissingHandlerErr(err) {
+		t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err)
+	}
+}
+
+// TestXrayAPI_E2E_NewClientTrafficIsCounted proves against a live core that a
+// counter appearing after the baseline poll is reported in full: xray creates a
+// user's counter on first use, and dropping that first sample lost a new
+// client's traffic for a whole polling interval.
+func TestXrayAPI_E2E_NewClientTrafficIsCounted(t *testing.T) {
+	const payload = 64 * 1024
+
+	backendLn, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		_, _ = w.Write(make([]byte, payload))
+	})}
+	go func() { _ = backend.Serve(backendLn) }()
+	t.Cleanup(func() { _ = backend.Close() })
+
+	proxyPort := freePort(t)
+	c := startE2ECore(t, []any{
+		map[string]any{
+			"listen": "127.0.0.1", "port": proxyPort, "protocol": "http", "tag": "http-in",
+			"settings": map[string]any{
+				"accounts": []any{map[string]any{"user": "e2e-fresh", "pass": "e2e-pass"}},
+			},
+		},
+	})
+
+	// Baseline poll: the client's counter does not exist yet.
+	if _, _, err := c.api.GetTraffic(); err != nil {
+		t.Fatalf("GetTraffic (baseline): %v", err)
+	}
+
+	proxyURL, err := url.Parse(fmt.Sprintf("http://e2e-fresh:[email protected]:%d", proxyPort))
+	if err != nil {
+		t.Fatal(err)
+	}
+	client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
+	resp, err := client.Get(fmt.Sprintf("http://%s/", backendLn.Addr().String()))
+	if err != nil {
+		t.Fatalf("proxied request: %v", err)
+	}
+	n, err := io.Copy(io.Discard, resp.Body)
+	resp.Body.Close()
+	if err != nil {
+		t.Fatalf("reading the proxied response: %v", err)
+	}
+	if n != payload {
+		t.Fatalf("proxied %d bytes, want %d", n, payload)
+	}
+	time.Sleep(500 * time.Millisecond)
+
+	_, clients, err := c.api.GetTraffic()
+	if err != nil {
+		t.Fatalf("GetTraffic: %v", err)
+	}
+	var got *ClientTraffic
+	for _, ct := range clients {
+		if ct.Email == "e2e-fresh" {
+			got = ct
+		}
+	}
+	if got == nil {
+		t.Fatalf("the new client's traffic was dropped; reported clients: %+v", clients)
+	}
+	if got.Down < payload {
+		t.Fatalf("downlink = %d, want at least the %d bytes that were proxied", got.Down, payload)
+	}
+}
+
+// TestXrayAPI_E2E_TestRoutePortRange keeps TestRoute from wrapping an
+// out-of-range port into the uint32 the core is asked about.
+func TestXrayAPI_E2E_TestRoutePortRange(t *testing.T) {
+	c := startE2ECore(t, nil)
+
+	for _, port := range []int{-1, 65536, 70000} {
+		if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: port}); err == nil {
+			t.Fatalf("TestRoute accepted the out-of-range port %d", port)
+		}
+	}
+	if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 65535}); err != nil {
+		t.Fatalf("TestRoute rejected the valid port 65535: %v", err)
+	}
+}