12 Commits ed9686bf29 ... 814cda3fb4

Autor SHA1 Mensagem Data
  MHSanaei 814cda3fb4 feat(xray): update xray-core to v26.7.11 and adapt panel há 23 horas atrás
  Dmitrii Ignatov affcf6c422 fix(link): strip query and trailing slash when parsing ss:// port (#5895) há 1 dia atrás
  MHSanaei cbd2940a63 fix(node): adopt a node inbound's host overrides into the master há 1 dia atrás
  MHSanaei e6bef229ae fix(web): opt panel pages out of Cloudflare Rocket Loader há 1 dia atrás
  MHSanaei 975b1f1acc fix(iplimit): ban a dead connection once instead of every scan há 1 dia atrás
  MHSanaei 6aa87f4e57 fix(clients): finish deleting from every inbound when one fails há 1 dia atrás
  MHSanaei 200ea09157 fix(node): never sweep a node's inbounds before their first adoption há 1 dia atrás
  MHSanaei fc625d8f66 fix(database): drop the legacy UNIQUE constraint on inbounds.port há 1 dia atrás
  MHSanaei c4448f4ea8 fix(clients): rename client record atomically with inbound settings há 1 dia atrás
  MHSanaei 3b731cd657 fix(clients): reuse stored credentials when re-adding an existing identity há 1 dia atrás
  MHSanaei 1c789c3e4d fix(script): confirm auto-detected public IPv4 before issuing IP certificate há 1 dia atrás
  MHSanaei 201d4731de fix(xhttp): stop XMUX maxConcurrency from reverting on save há 1 dia atrás
69 ficheiros alterados com 1831 adições e 123 exclusões
  1. 2 2
      .github/workflows/release.yml
  2. 1 1
      DockerInit.sh
  3. 3 1
      docs/content/docs/en/config/transports.mdx
  4. 3 1
      docs/content/docs/fa/config/transports.mdx
  5. 3 1
      docs/content/docs/ru/config/transports.mdx
  6. 2 1
      docs/content/docs/zh/config/transports.mdx
  7. 38 0
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  8. 4 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  9. 6 2
      frontend/src/lib/xray/outbound-form-adapter.ts
  10. 3 1
      frontend/src/lib/xray/outbound-link-parser.ts
  11. 4 8
      frontend/src/lib/xray/stream-wire-normalize.ts
  12. 4 2
      frontend/src/pages/clients/ClientFormModal.tsx
  13. 1 1
      frontend/src/pages/inbounds/form/security/reality.tsx
  14. 19 4
      frontend/src/pages/inbounds/form/transport/xhttp.tsx
  15. 2 2
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  16. 18 1
      frontend/src/pages/xray/outbounds/transport/xhttp.tsx
  17. 0 2
      frontend/src/schemas/primitives/options.ts
  18. 6 6
      frontend/src/schemas/protocols/shared/vmess.ts
  19. 2 2
      frontend/src/schemas/protocols/stream/finalmask.ts
  20. 22 4
      frontend/src/schemas/protocols/stream/index.ts
  21. 15 1
      frontend/src/schemas/protocols/stream/xhttp.ts
  22. 10 0
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  23. 8 0
      frontend/src/test/golden/fixtures/finalmask/tcp-mask.json
  24. 32 0
      frontend/src/test/inbound-form-adapter.test.ts
  25. 23 0
      frontend/src/test/outbound-form-adapter.test.ts
  26. 11 5
      frontend/src/test/stream-wire-normalize.test.ts
  27. 13 0
      frontend/src/test/stream.test.ts
  28. 13 1
      frontend/vite.config.js
  29. 6 6
      go.mod
  30. 12 12
      go.sum
  31. 18 0
      install.sh
  32. 261 1
      internal/database/db.go
  33. 76 0
      internal/database/inbound_port_unique_migration_test.go
  34. 35 4
      internal/database/model/model.go
  35. 43 0
      internal/database/model/model_shadowsocks_removed_test.go
  36. 117 0
      internal/database/removed_cipher_migration_test.go
  37. 1 5
      internal/sub/clash_service.go
  38. 1 4
      internal/sub/json_service.go
  39. 14 1
      internal/sub/service.go
  40. 15 0
      internal/sub/service_test.go
  41. 17 4
      internal/util/link/outbound.go
  42. 110 0
      internal/util/link/outbound_test.go
  43. 1 1
      internal/web/controller/dist.go
  44. 68 0
      internal/web/job/check_client_ip_frozen_ban_test.go
  45. 34 2
      internal/web/job/check_client_ip_job.go
  46. 19 0
      internal/web/job/node_traffic_sync_job.go
  47. 20 0
      internal/web/runtime/remote.go
  48. 20 6
      internal/web/service/client_bulk.go
  49. 54 0
      internal/web/service/client_create_reuse_test.go
  50. 43 8
      internal/web/service/client_crud.go
  51. 64 0
      internal/web/service/client_delete_continue_test.go
  52. 13 0
      internal/web/service/client_inbound_apply.go
  53. 88 0
      internal/web/service/client_update_rename_test.go
  54. 15 0
      internal/web/service/host.go
  55. 29 1
      internal/web/service/inbound.go
  56. 45 0
      internal/web/service/inbound_node.go
  57. 26 0
      internal/web/service/inbound_node_reconcile_test.go
  58. 9 0
      internal/web/service/node.go
  59. 77 0
      internal/web/service/node_host_adopt_test.go
  60. 38 7
      internal/web/service/outbound_subscription.go
  61. 57 0
      internal/web/service/stream_network_alias_test.go
  62. 15 0
      internal/web/service/xray_setting.go
  63. 17 2
      internal/xray/api.go
  64. 4 0
      internal/xray/config.go
  65. 1 0
      internal/xray/hot_diff.go
  66. 6 0
      internal/xray/hot_diff_test.go
  67. 42 0
      internal/xray/outbound_validation_test.go
  68. 16 0
      update.sh
  69. 16 10
      x-ui.sh

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

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

+ 1 - 1
DockerInit.sh

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

+ 3 - 1
docs/content/docs/en/config/transports.mdx

@@ -122,7 +122,9 @@ Only valid when the protocol is **Hysteria2**.
 disguise transports that don't carry TLS (like mKCP) or add a second skin on top of
 disguise transports that don't carry TLS (like mKCP) or add a second skin on top of
 TLS. Masks are configured per direction:
 TLS. Masks are configured per direction:
 
 
-- **TCP masks** — `fragment`, `sudoku`, `header-custom`.
+- **TCP masks** — `fragment`, `sudoku`, `header-custom`, `xmc` (disguises the
+  stream as Minecraft protocol traffic; requires a password, with optional
+  hostname and player usernames).
 - **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
 - **UDP masks** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
   `noise`, `sudoku`, `realm`. (`mkcp-legacy` reproduces the old mKCP header
   `noise`, `sudoku`, `realm`. (`mkcp-legacy` reproduces the old mKCP header
   obfuscation.)
   obfuscation.)

+ 3 - 1
docs/content/docs/fa/config/transports.mdx

@@ -123,7 +123,9 @@ icon: Network
 انتقال‌هایی را که TLS حمل نمی‌کنند (مانند mKCP) استتار کند یا پوسته‌ای دوم روی TLS
 انتقال‌هایی را که TLS حمل نمی‌کنند (مانند mKCP) استتار کند یا پوسته‌ای دوم روی TLS
 بیفزاید. ماسک‌ها برای هر جهت پیکربندی می‌شوند:
 بیفزاید. ماسک‌ها برای هر جهت پیکربندی می‌شوند:
 
 
-- **ماسک‌های TCP** — `fragment`، `sudoku`، `header-custom`.
+- **ماسک‌های TCP** — `fragment`، `sudoku`، `header-custom`، `xmc` (ترافیک را به شکل
+  پروتکل Minecraft استتار می‌کند؛ گذرواژه الزامی است و نام میزبان و نام‌های بازیکن
+  اختیاری‌اند).
 - **ماسک‌های UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`،
 - **ماسک‌های UDP** — `salamander`، `mkcp-legacy`، `header-custom`، `xdns`، `xicmp`،
   `noise`، `sudoku`، `realm`. (`mkcp-legacy` همان مبهم‌سازی قدیمی هدر mKCP را
   `noise`، `sudoku`، `realm`. (`mkcp-legacy` همان مبهم‌سازی قدیمی هدر mKCP را
   بازتولید می‌کند.)
   بازتولید می‌کند.)

+ 3 - 1
docs/content/docs/ru/config/transports.mdx

@@ -125,7 +125,9 @@ HTTPUpgrade — это одноразовый HTTP/1.1 `Upgrade` без фрей
 поэтому он может замаскировать транспорты, не несущие TLS (например, mKCP), или
 поэтому он может замаскировать транспорты, не несущие TLS (например, mKCP), или
 добавить вторую оболочку поверх TLS. Маски настраиваются по направлениям:
 добавить вторую оболочку поверх TLS. Маски настраиваются по направлениям:
 
 
-- **TCP-маски** — `fragment`, `sudoku`, `header-custom`.
+- **TCP-маски** — `fragment`, `sudoku`, `header-custom`, `xmc` (маскирует поток
+  под трафик протокола Minecraft; требуется пароль, имя хоста и имена игроков
+  опциональны).
 - **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
 - **UDP-маски** — `salamander`, `mkcp-legacy`, `header-custom`, `xdns`, `xicmp`,
   `noise`, `sudoku`, `realm`. (`mkcp-legacy` воспроизводит старую обфускацию
   `noise`, `sudoku`, `realm`. (`mkcp-legacy` воспроизводит старую обфускацию
   заголовка mKCP.)
   заголовка mKCP.)

+ 2 - 1
docs/content/docs/zh/config/transports.mdx

@@ -117,7 +117,8 @@ Session-ID 字段(`sessionIDPlacement`、`sessionIDKey`、`sessionIDTable`、
 **FinalMask** 在传输方式和安全层**之后**包裹流量,因此它既能伪装那些承载不了 TLS 的
 **FinalMask** 在传输方式和安全层**之后**包裹流量,因此它既能伪装那些承载不了 TLS 的
 传输(如 mKCP),也能在 TLS 之上再加一层外壳。掩码按方向分别配置:
 传输(如 mKCP),也能在 TLS 之上再加一层外壳。掩码按方向分别配置:
 
 
-- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`。
+- **TCP 掩码** — `fragment`、`sudoku`、`header-custom`、`xmc`(把流量伪装成
+  Minecraft 协议;密码必填,主机名和玩家用户名可选)。
 - **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、
 - **UDP 掩码** — `salamander`、`mkcp-legacy`、`header-custom`、`xdns`、`xicmp`、
   `noise`、`sudoku`、`realm`。(`mkcp-legacy` 重现旧版 mKCP 的头部混淆。)
   `noise`、`sudoku`、`realm`。(`mkcp-legacy` 重现旧版 mKCP 的头部混淆。)
 - **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行
 - **QUIC 参数** — 拥塞控制(`reno`、`bbr`、`brutal`、`force-brutal`)、Brutal 上/下行

+ 38 - 0
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -81,6 +81,8 @@ function defaultTcpMaskSettings(type: string): Record<string, unknown> {
       };
       };
     case 'header-custom':
     case 'header-custom':
       return { clients: [], servers: [] };
       return { clients: [], servers: [] };
+    case 'xmc':
+      return { hostname: '', usernames: [], password: RandomUtil.randomLowerAndNum(16) };
     default:
     default:
       return {};
       return {};
   }
   }
@@ -294,6 +296,7 @@ function TcpMaskItem({
             { value: 'fragment', label: 'Fragment' },
             { value: 'fragment', label: 'Fragment' },
             { value: 'header-custom', label: 'Header Custom' },
             { value: 'header-custom', label: 'Header Custom' },
             { value: 'sudoku', label: 'Sudoku' },
             { value: 'sudoku', label: 'Sudoku' },
+            { value: 'xmc', label: 'XMC (Minecraft)' },
           ]}
           ]}
         />
         />
       </Form.Item>
       </Form.Item>
@@ -371,6 +374,41 @@ function TcpMaskItem({
               />
               />
             );
             );
           }
           }
+          if (type === 'xmc') {
+            return (
+              <>
+                <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>
+                <Form.Item label="Password" required>
+                  <Space.Compact block>
+                    <Form.Item
+                      name={[fieldName, 'settings', 'password']}
+                      noStyle
+                      rules={[{ required: true, message: 'Password is required' }]}
+                    >
+                      <Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
+                    </Form.Item>
+                    <Button
+                      icon={<ReloadOutlined />}
+                      aria-label={t('regenerate')}
+                      onClick={() => form.setFieldValue(
+                        [...absolutePath, 'settings', 'password'],
+                        RandomUtil.randomLowerAndNum(16),
+                      )}
+                    />
+                  </Space.Compact>
+                </Form.Item>
+              </>
+            );
+          }
           return null;
           return null;
         }}
         }}
       </Form.Item>
       </Form.Item>

+ 4 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -124,6 +124,10 @@ const NETWORK_SETTINGS_KEY: Record<string, string> = {
 };
 };
 
 
 function healStreamNetworkKey(stream: Record<string, unknown>): void {
 function healStreamNetworkKey(stream: Record<string, unknown>): void {
+  if (typeof stream.method === 'string' && stream.method !== '') {
+    stream.network = stream.method;
+  }
+  delete stream.method;
   const network = typeof stream.network === 'string' ? stream.network : '';
   const network = typeof stream.network === 'string' ? stream.network : '';
   const key = NETWORK_SETTINGS_KEY[network];
   const key = NETWORK_SETTINGS_KEY[network];
   if (!key) return;
   if (!key) return;

+ 6 - 2
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -107,7 +107,7 @@ function vmessFromWire(raw: Raw): VmessOutboundFormSettings {
     id: asString(u.id),
     id: asString(u.id),
     security: ((): VmessOutboundFormSettings['security'] => {
     security: ((): VmessOutboundFormSettings['security'] => {
       const s = asString(u.security);
       const s = asString(u.security);
-      const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto', 'none', 'zero'];
+      const allowed = ['aes-128-gcm', 'chacha20-poly1305', 'auto'];
       return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
       return (allowed.includes(s) ? s : 'auto') as VmessOutboundFormSettings['security'];
     })(),
     })(),
   };
   };
@@ -387,10 +387,14 @@ export interface RawOutboundRow {
   mux?: unknown;
   mux?: unknown;
 }
 }
 
 
-export const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
+const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 
 
 function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
 function hydrateStreamForm(stream: Raw): OutboundStreamFormValues {
   const next = { ...stream };
   const next = { ...stream };
+  if (typeof next.method === 'string' && next.method !== '') {
+    next.network = next.method;
+  }
+  delete next.method;
   const xh = next.xhttpSettings;
   const xh = next.xhttpSettings;
   if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
   if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
     const xhttp = { ...(xh as Raw) };
     const xhttp = { ...(xh as Raw) };

+ 3 - 1
frontend/src/lib/xray/outbound-link-parser.ts

@@ -360,6 +360,8 @@ export function parseVmessLink(link: string): Raw | null {
     }
     }
 
 
     const port = Number(json.port) || 443;
     const port = Number(json.port) || 443;
+    const rawScy = (json.scy as string) || 'auto';
+    const userSecurity = rawScy === 'none' || rawScy === 'zero' ? 'auto' : rawScy;
     return {
     return {
       protocol: 'vmess',
       protocol: 'vmess',
       tag: typeof json.ps === 'string' ? json.ps : '',
       tag: typeof json.ps === 'string' ? json.ps : '',
@@ -367,7 +369,7 @@ export function parseVmessLink(link: string): Raw | null {
         vnext: [{
         vnext: [{
           address: json.add ?? '',
           address: json.add ?? '',
           port,
           port,
-          users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }],
+          users: [{ id: json.id ?? '', security: userSecurity }],
         }],
         }],
       },
       },
       streamSettings: stream,
       streamSettings: stream,

+ 4 - 8
frontend/src/lib/xray/stream-wire-normalize.ts

@@ -46,7 +46,7 @@ function hasMeaningfulHeaders(headers: unknown): boolean {
 // Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
 // Upper bound of an xray-core Int32Range value: "16-32" -> 32, "4" -> 4,
 // 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
 // 4 -> 4, "" / null -> 0. xmux fields are ranges, and xray-core keys its
 // mutual-exclusivity check on the `.To` (upper) side.
 // mutual-exclusivity check on the `.To` (upper) side.
-function int32RangeUpper(v: unknown): number {
+export function int32RangeUpper(v: unknown): number {
   if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
   if (typeof v === 'number') return Number.isFinite(v) ? v : 0;
   if (typeof v !== 'string') return 0;
   if (typeof v !== 'string') return 0;
   const trimmed = v.trim();
   const trimmed = v.trim();
@@ -57,13 +57,9 @@ function int32RangeUpper(v: unknown): number {
 }
 }
 
 
 // xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
 // xray-core's XmuxConfig rejects a config that sets BOTH maxConnections
-// and maxConcurrency ("maxConnections cannot be specified together with
-// maxConcurrency"). The panel pre-fills maxConcurrency ("16-32") whenever
-// XMUX is enabled, so any explicit maxConnections would otherwise always
-// collide and make xray refuse the config. maxConnections defaults to 0
-// (off), so a positive value is an explicit opt-in to connection-pool
-// mode — honor it and drop the leftover default maxConcurrency, matching
-// core's "one strategy at a time" semantics.
+// and maxConcurrency. A positive maxConnections is an explicit opt-in to
+// connection-pool mode — honor it and drop the leftover maxConcurrency
+// default that load-time hydration backfills onto older saved configs.
 function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
 function resolveXmuxExclusivity(xmux: Record<string, unknown>): Record<string, unknown> {
   if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
   if (int32RangeUpper(xmux.maxConnections) > 0 && int32RangeUpper(xmux.maxConcurrency) > 0) {
     const out = { ...xmux };
     const out = { ...xmux };

+ 4 - 2
frontend/src/pages/clients/ClientFormModal.tsx

@@ -36,7 +36,7 @@ import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2
 import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
 import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
 
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
-const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305', 'none', 'zero'] as const;
+const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
 
 
 const MULTI_CLIENT_PROTOCOLS = new Set([
 const MULTI_CLIENT_PROTOCOLS = new Set([
   'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto',
   'shadowsocks', 'vless', 'vmess', 'trojan', 'hysteria', 'wireguard', 'mtproto',
@@ -217,7 +217,9 @@ export default function ClientFormModal({
         password: client.password || '',
         password: client.password || '',
         auth: client.auth || '',
         auth: client.auth || '',
         flow: client.flow || '',
         flow: client.flow || '',
-        security: client.security || 'auto',
+        security: !client.security || client.security === 'none' || client.security === 'zero'
+          ? 'auto'
+          : client.security,
         reverseTag: client.reverse?.tag || '',
         reverseTag: client.reverse?.tag || '',
         totalGB: bytesToGB(client.totalGB || 0),
         totalGB: bytesToGB(client.totalGB || 0),
         reset: Number(client.reset) || 0,
         reset: Number(client.reset) || 0,

+ 1 - 1
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -128,7 +128,7 @@ export default function RealityForm({
         name={['streamSettings', 'realitySettings', 'minClientVer']}
         name={['streamSettings', 'realitySettings', 'minClientVer']}
         label={t('pages.inbounds.form.minClientVer')}
         label={t('pages.inbounds.form.minClientVer')}
       >
       >
-        <Input placeholder="25.9.11" />
+        <Input placeholder="26.3.27" />
       </FormField>
       </FormField>
       <FormField
       <FormField
         name={['streamSettings', 'realitySettings', 'maxClientVer']}
         name={['streamSettings', 'realitySettings', 'maxClientVer']}

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

@@ -4,10 +4,9 @@ import { useFormContext, useWatch } from 'react-hook-form';
 
 
 import { HeaderMapEditor } from '@/components/form';
 import { HeaderMapEditor } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
 import { FormField } from '@/components/form/rhf';
-import { XHTTP_SESSION_ID_TABLES, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+import { XHTTP_SESSION_ID_TABLES, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
 import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
 import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
-
-const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
+import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize';
 
 
 function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
 function antdValidatorToRhf(fn: (rule: unknown, value: unknown) => Promise<void>) {
   return async (value: unknown): Promise<true | string> => {
   return async (value: unknown): Promise<true | string> => {
@@ -36,7 +35,21 @@ export default function XhttpForm() {
     const existing = getValues('streamSettings.xhttpSettings.xmux');
     const existing = getValues('streamSettings.xhttpSettings.xmux');
     const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
     const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
     if (hasValues) return;
     if (hasValues) return;
-    setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
+    setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
+  }
+
+  function onXmuxMaxConcurrencyChange(value: unknown) {
+    if (int32RangeUpper(value) <= 0) return;
+    if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConnections')) > 0) {
+      setValue('streamSettings.xhttpSettings.xmux.maxConnections', 0);
+    }
+  }
+
+  function onXmuxMaxConnectionsChange(value: unknown) {
+    if (int32RangeUpper(value) <= 0) return;
+    if (int32RangeUpper(getValues('streamSettings.xhttpSettings.xmux.maxConcurrency')) > 0) {
+      setValue('streamSettings.xhttpSettings.xmux.maxConcurrency', '');
+    }
   }
   }
 
 
   return (
   return (
@@ -295,12 +308,14 @@ export default function XhttpForm() {
           <FormField
           <FormField
             label={t('pages.xray.outboundForm.maxConcurrency')}
             label={t('pages.xray.outboundForm.maxConcurrency')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+            onAfterChange={onXmuxMaxConcurrencyChange}
           >
           >
             <Input placeholder="16-32" />
             <Input placeholder="16-32" />
           </FormField>
           </FormField>
           <FormField
           <FormField
             label={t('pages.xray.outboundForm.maxConnections')}
             label={t('pages.xray.outboundForm.maxConnections')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+            onAfterChange={onXmuxMaxConnectionsChange}
           >
           >
             <Input placeholder="0" />
             <Input placeholder="0" />
           </FormField>
           </FormField>

+ 2 - 2
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -17,11 +17,11 @@ import { FormField, rhfZodValidate } from '@/components/form/rhf';
 import { JsonEditor } from '@/components/form';
 import { JsonEditor } from '@/components/form';
 import { Wireguard } from '@/utils';
 import { Wireguard } from '@/utils';
 import {
 import {
-  XMUX_DEFAULTS,
   formValuesToWirePayload,
   formValuesToWirePayload,
   rawOutboundToFormValues,
   rawOutboundToFormValues,
 } from '@/lib/xray/outbound-form-adapter';
 } from '@/lib/xray/outbound-form-adapter';
 import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
 import { parseOutboundLink } from '@/lib/xray/outbound-link-parser';
+import { XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
 import {
 import {
   OutboundFormBaseSchema,
   OutboundFormBaseSchema,
   type OutboundFormValues,
   type OutboundFormValues,
@@ -255,7 +255,7 @@ export default function OutboundFormModal({
     const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
     const existing = methods.getValues('streamSettings.xhttpSettings.xmux');
     const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
     const hasValues = existing && typeof existing === 'object' && Object.keys(existing).length > 0;
     if (hasValues) return;
     if (hasValues) return;
-    methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_DEFAULTS });
+    methods.setValue('streamSettings.xhttpSettings.xmux', { ...XMUX_FRESH_DEFAULTS });
   }
   }
 
 
   const duplicateTag = useMemo(() => {
   const duplicateTag = useMemo(() => {

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

@@ -5,6 +5,7 @@ import { useFormContext, useWatch } from 'react-hook-form';
 import { HeaderMapEditor } from '@/components/form';
 import { HeaderMapEditor } from '@/components/form';
 import { FormField } from '@/components/form/rhf';
 import { FormField } from '@/components/form/rhf';
 import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
 import { validateSessionIDLength, validateSessionIDTable } from '@/lib/xray/xhttp-session-id';
+import { int32RangeUpper } from '@/lib/xray/stream-wire-normalize';
 import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
 import { XHTTP_SESSION_ID_TABLES } from '@/schemas/protocols/stream/xhttp';
 
 
 import { MODE_OPTIONS } from '../outbound-form-constants';
 import { MODE_OPTIONS } from '../outbound-form-constants';
@@ -28,7 +29,7 @@ const XH = 'streamSettings.xhttpSettings';
 
 
 export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
 export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
-  const { control } = useFormContext();
+  const { control, getValues, setValue } = useFormContext();
   const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
   const mode = useWatch({ control, name: `${XH}.mode` }) as string | undefined;
   const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
   const obfs = !!useWatch({ control, name: `${XH}.xPaddingObfsMode` });
   const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
   const sessionPlacement = useWatch({ control, name: `${XH}.sessionIDPlacement` }) as string | undefined;
@@ -37,6 +38,20 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
   const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
   const uplinkDataPlacement = useWatch({ control, name: `${XH}.uplinkDataPlacement` }) as string | undefined;
   const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
   const enableXmux = !!useWatch({ control, name: `${XH}.enableXmux` });
 
 
+  function onXmuxMaxConcurrencyChange(value: unknown) {
+    if (int32RangeUpper(value) <= 0) return;
+    if (int32RangeUpper(getValues(`${XH}.xmux.maxConnections`)) > 0) {
+      setValue(`${XH}.xmux.maxConnections`, 0);
+    }
+  }
+
+  function onXmuxMaxConnectionsChange(value: unknown) {
+    if (int32RangeUpper(value) <= 0) return;
+    if (int32RangeUpper(getValues(`${XH}.xmux.maxConcurrency`)) > 0) {
+      setValue(`${XH}.xmux.maxConcurrency`, '');
+    }
+  }
+
   return (
   return (
     <>
     <>
       <FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
       <FormField label={t('host')} name={['streamSettings', 'xhttpSettings', 'host']}>
@@ -283,12 +298,14 @@ export default function XhttpForm({ onXmuxToggle }: XhttpFormProps) {
           <FormField
           <FormField
             label={t('pages.xray.outboundForm.maxConcurrency')}
             label={t('pages.xray.outboundForm.maxConcurrency')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConcurrency']}
+            onAfterChange={onXmuxMaxConcurrencyChange}
           >
           >
             <Input placeholder="16-32" />
             <Input placeholder="16-32" />
           </FormField>
           </FormField>
           <FormField
           <FormField
             label={t('pages.xray.outboundForm.maxConnections')}
             label={t('pages.xray.outboundForm.maxConnections')}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
             name={['streamSettings', 'xhttpSettings', 'xmux', 'maxConnections']}
+            onAfterChange={onXmuxMaxConnectionsChange}
           >
           >
             <Input placeholder="0" />
             <Input placeholder="0" />
           </FormField>
           </FormField>

+ 0 - 2
frontend/src/schemas/primitives/options.ts

@@ -30,8 +30,6 @@ export const USERS_SECURITY = Object.freeze({
   AES_128_GCM: 'aes-128-gcm',
   AES_128_GCM: 'aes-128-gcm',
   CHACHA20_POLY1305: 'chacha20-poly1305',
   CHACHA20_POLY1305: 'chacha20-poly1305',
   AUTO: 'auto',
   AUTO: 'auto',
-  NONE: 'none',
-  ZERO: 'zero',
 });
 });
 
 
 export const MODE_OPTION = Object.freeze({
 export const MODE_OPTION = Object.freeze({

+ 6 - 6
frontend/src/schemas/protocols/shared/vmess.ts

@@ -4,16 +4,16 @@ const VmessSecurityEnum = z.enum([
   'aes-128-gcm',
   'aes-128-gcm',
   'chacha20-poly1305',
   'chacha20-poly1305',
   'auto',
   'auto',
-  'none',
-  'zero',
 ]);
 ]);
 
 
 // Legacy rows persisted `security: ""` (especially on VMess inbounds
 // Legacy rows persisted `security: ""` (especially on VMess inbounds
-// created before the enum was nailed down). Preprocess maps the empty
-// string back to the documented default so existing data parses cleanly
-// — subsequent writes serialize the normalized value.
+// created before the enum was nailed down), and rows predating xray-core
+// v26.7.11 may still hold the removed "none"/"zero" values that the core
+// now treats as "auto". Preprocess maps all of them to the documented
+// default so existing data parses cleanly — subsequent writes serialize
+// the normalized value.
 export const VmessSecuritySchema = z.preprocess(
 export const VmessSecuritySchema = z.preprocess(
-  (val) => (val === '' ? 'auto' : val),
+  (val) => (val === '' || val === 'none' || val === 'zero' ? 'auto' : val),
   VmessSecurityEnum,
   VmessSecurityEnum,
 );
 );
 export type VmessSecurity = z.infer<typeof VmessSecurityEnum>;
 export type VmessSecurity = z.infer<typeof VmessSecurityEnum>;

+ 2 - 2
frontend/src/schemas/protocols/stream/finalmask.ts

@@ -5,12 +5,12 @@ import { z } from 'zod';
 // plus optional QUIC tuning. The `settings` sub-object is polymorphic on
 // plus optional QUIC tuning. The `settings` sub-object is polymorphic on
 // `type`; we model the wire-faithful shape with a permissive
 // `type`; we model the wire-faithful shape with a permissive
 // record-of-unknown for `settings` and leave per-type tightening to
 // record-of-unknown for `settings` and leave per-type tightening to
-// Step 6 — there are 8 UDP mask types plus 3 TCP mask types, each with
+// Step 6 — there are 8 UDP mask types plus 4 TCP mask types, each with
 // distinct setting fields, and modeling them all as discriminated unions
 // distinct setting fields, and modeling them all as discriminated unions
 // here would dwarf the rest of the stream module without buying anything
 // here would dwarf the rest of the stream module without buying anything
 // the safety net doesn't already cover.
 // the safety net doesn't already cover.
 
 
-export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom']);
+export const TcpMaskTypeSchema = z.enum(['fragment', 'sudoku', 'header-custom', 'xmc']);
 export type TcpMaskType = z.infer<typeof TcpMaskTypeSchema>;
 export type TcpMaskType = z.infer<typeof TcpMaskTypeSchema>;
 
 
 export const TcpMaskSchema = z.object({
 export const TcpMaskSchema = z.object({

+ 22 - 4
frontend/src/schemas/protocols/stream/index.ts

@@ -52,10 +52,28 @@ const TransportNetworkSettingsSchema = z.discriminatedUnion('network', [
 // mode. The transportless branch accepts that shape (network absent), while a
 // mode. The transportless branch accepts that shape (network absent), while a
 // present-but-invalid network still fails both branches so a typo can't slip
 // present-but-invalid network still fails both branches so a typo can't slip
 // through. `network: never().optional()` reads as "this key must be absent".
 // through. `network: never().optional()` reads as "this key must be absent".
-export const NetworkSettingsSchema = z.union([
-  TransportNetworkSettingsSchema,
-  z.object({ network: z.never().optional() }),
-]);
+//
+// The preprocess folds `method` — xray-core v26.7.11's preferred alias for
+// `network`, which wins over `network` when both are present — back into the
+// panel-canonical `network` key, so imported/pasted configs keyed on the
+// alias don't silently match the transportless branch and lose their
+// transport.
+export const NetworkSettingsSchema = z.preprocess(
+  (val) => {
+    if (val && typeof val === 'object' && 'method' in val) {
+      const { method, ...rest } = val as Record<string, unknown>;
+      if (typeof method === 'string' && method !== '') {
+        return { ...rest, network: method };
+      }
+      return rest;
+    }
+    return val;
+  },
+  z.union([
+    TransportNetworkSettingsSchema,
+    z.object({ network: z.never().optional() }),
+  ]),
+);
 export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;
 export type NetworkSettings = z.infer<typeof NetworkSettingsSchema>;
 
 
 // Orthogonal extras that ride alongside the network and security branches.
 // Orthogonal extras that ride alongside the network and security branches.

+ 15 - 1
frontend/src/schemas/protocols/stream/xhttp.ts

@@ -15,9 +15,14 @@ export type XHttpMode = z.infer<typeof XHttpModeSchema>;
 // XMUX is the connection-multiplexing layer xHTTP uses to fan out
 // XMUX is the connection-multiplexing layer xHTTP uses to fan out
 // parallel requests over a small pool of upstream connections. Fields
 // parallel requests over a small pool of upstream connections. Fields
 // are strings because they accept dash-range values like '16-32'.
 // are strings because they accept dash-range values like '16-32'.
+// maxConcurrency and maxConnections are mutually exclusive strategies
+// (xray-core rejects a config that sets both), so the bare schema
+// default keeps only one of them non-zero — a non-zero maxConnections
+// default resurrected on load made every re-save silently delete the
+// user's maxConcurrency.
 export const XHttpXmuxSchema = z.object({
 export const XHttpXmuxSchema = z.object({
   maxConcurrency: z.string().default('16-32'),
   maxConcurrency: z.string().default('16-32'),
-  maxConnections: z.union([z.string(), z.number()]).default(6),
+  maxConnections: z.union([z.string(), z.number()]).default(0),
   cMaxReuseTimes: z.union([z.string(), z.number()]).default(0),
   cMaxReuseTimes: z.union([z.string(), z.number()]).default(0),
   hMaxRequestTimes: z.string().default('600-900'),
   hMaxRequestTimes: z.string().default('600-900'),
   hMaxReusableSecs: z.string().default('1800-3000'),
   hMaxReusableSecs: z.string().default('1800-3000'),
@@ -25,6 +30,15 @@ export const XHttpXmuxSchema = z.object({
 });
 });
 export type XHttpXmux = z.infer<typeof XHttpXmuxSchema>;
 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.
+export const XMUX_FRESH_DEFAULTS: XHttpXmux = {
+  ...XHttpXmuxSchema.parse({}),
+  maxConcurrency: '',
+  maxConnections: 6,
+};
+
 // Predefined sessionIDTable names xray-core accepts as a shorthand for a
 // Predefined sessionIDTable names xray-core accepts as a shorthand for a
 // charset (splithttp.PredefinedTable, xray-core #6258). A literal ASCII
 // charset (splithttp.PredefinedTable, xray-core #6258). A literal ASCII
 // charset string is also accepted.
 // charset string is also accepted.

+ 10 - 0
frontend/src/test/__snapshots__/finalmask.test.ts.snap

@@ -167,6 +167,16 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`
       },
       },
       "type": "header-custom",
       "type": "header-custom",
     },
     },
+    {
+      "settings": {
+        "hostname": "mc.example.com",
+        "password": "s3cr3t",
+        "usernames": [
+          "Dream",
+        ],
+      },
+      "type": "xmc",
+    },
   ],
   ],
   "udp": [],
   "udp": [],
 }
 }

+ 8 - 0
frontend/src/test/golden/fixtures/finalmask/tcp-mask.json

@@ -25,6 +25,14 @@
         ],
         ],
         "errors": []
         "errors": []
       }
       }
+    },
+    {
+      "type": "xmc",
+      "settings": {
+        "hostname": "mc.example.com",
+        "usernames": ["Dream"],
+        "password": "s3cr3t"
+      }
     }
     }
   ]
   ]
 }
 }

+ 32 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -353,3 +353,35 @@ describe('legacy xhttp session keys on edit (#5621)', () => {
     expect(out.sessionKey).toBeUndefined();
     expect(out.sessionKey).toBeUndefined();
   });
   });
 });
 });
+
+describe('xhttp xmux maxConcurrency survives a load/re-save round-trip', () => {
+  const xmuxRow: RawInboundRow = {
+    ...vlessRow,
+    streamSettings: {
+      network: 'xhttp',
+      security: 'none',
+      xhttpSettings: {
+        path: '/xh',
+        mode: 'auto',
+        xmux: { maxConcurrency: '1-2' },
+      },
+    },
+  };
+
+  it('rawInboundToFormValues does not resurrect a non-zero maxConnections', () => {
+    const values = rawInboundToFormValues(xmuxRow);
+    const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
+    expect(xhttp.enableXmux).toBe(true);
+    const xmux = xhttp.xmux as Record<string, unknown>;
+    expect(xmux.maxConcurrency).toBe('1-2');
+    expect(xmux.maxConnections).toBe(0);
+  });
+
+  it('formValuesToWirePayload keeps maxConcurrency on an unedited re-save', () => {
+    const values = rawInboundToFormValues(xmuxRow);
+    const payload = formValuesToWirePayload(values);
+    const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
+    const xmux = stream.xhttpSettings.xmux as Record<string, unknown>;
+    expect(xmux.maxConcurrency).toBe('1-2');
+  });
+});

+ 23 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -521,6 +521,29 @@ describe('outbound-form-adapter: xhttp xmux toggle', () => {
     const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
     const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
     expect(wireXhttp).not.toHaveProperty('xmux');
     expect(wireXhttp).not.toHaveProperty('xmux');
   });
   });
+
+  it('keeps a saved maxConcurrency through a load/re-save round-trip', () => {
+    const wire = {
+      ...xmuxWire,
+      streamSettings: {
+        ...xmuxWire.streamSettings,
+        xhttpSettings: {
+          ...xmuxWire.streamSettings.xhttpSettings,
+          xmux: { maxConcurrency: '1-2' },
+        },
+      },
+    };
+    const form = rawOutboundToFormValues(wire);
+    const xhttp = (form.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
+    const xmux = xhttp.xmux as Record<string, unknown>;
+    expect(xmux.maxConcurrency).toBe('1-2');
+    expect(xmux.maxConnections).toBe(0);
+
+    const back = formValuesToWirePayload(form);
+    const wireXhttp = (back.streamSettings as Record<string, unknown>).xhttpSettings as Record<string, unknown>;
+    const wireXmux = wireXhttp.xmux as Record<string, unknown>;
+    expect(wireXmux.maxConcurrency).toBe('1-2');
+  });
 });
 });
 
 
 describe('outbound-form-adapter: full optional-block round-trip', () => {
 describe('outbound-form-adapter: full optional-block round-trip', () => {

+ 11 - 5
frontend/src/test/stream-wire-normalize.test.ts

@@ -12,7 +12,7 @@ import {
 import { InboundFormSchema } from '@/schemas/forms/inbound-form';
 import { InboundFormSchema } from '@/schemas/forms/inbound-form';
 import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
 import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
 import type { InboundFormValues } from '@/schemas/forms/inbound-form';
 import type { InboundFormValues } from '@/schemas/forms/inbound-form';
-import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
+import { XHttpXmuxSchema, XMUX_FRESH_DEFAULTS } from '@/schemas/protocols/stream/xhttp';
 
 
 describe('validateRealityTarget', () => {
 describe('validateRealityTarget', () => {
   it('accepts host:port and bare port', () => {
   it('accepts host:port and bare port', () => {
@@ -153,19 +153,25 @@ describe('normalizeXhttpForWire stream-one', () => {
     expect(xmux.maxConnections).toBe('8');
     expect(xmux.maxConnections).toBe('8');
   });
   });
 
 
-  it('defaults xmux maxConnections to 6 (xray-core anti-RKN default) and drops maxConcurrency on the wire', () => {
-    expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(6);
+  it('bare schema defaults keep only one exclusive xmux strategy non-zero', () => {
+    expect(XHttpXmuxSchema.parse({}).maxConnections).toBe(0);
+    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);
+    expect(XMUX_FRESH_DEFAULTS.maxConcurrency).toBe('');
 
 
     const out = normalizeXhttpForWire({
     const out = normalizeXhttpForWire({
       path: '/app',
       path: '/app',
       mode: 'stream-one',
       mode: 'stream-one',
       enableXmux: true,
       enableXmux: true,
-      xmux: XHttpXmuxSchema.parse({}),
+      xmux: XMUX_FRESH_DEFAULTS,
     }, 'outbound');
     }, 'outbound');
 
 
     const xmux = out.xmux as Record<string, unknown>;
     const xmux = out.xmux as Record<string, unknown>;
     expect(xmux.maxConnections).toBe(6);
     expect(xmux.maxConnections).toBe(6);
-    expect(xmux).not.toHaveProperty('maxConcurrency');
+    expect(xmux.maxConcurrency).toBe('');
   });
   });
 });
 });
 
 

+ 13 - 0
frontend/src/test/stream.test.ts

@@ -24,3 +24,16 @@ describe('NetworkSettingsSchema fixtures', () => {
     });
     });
   }
   }
 });
 });
+
+describe('NetworkSettingsSchema method alias', () => {
+  it('folds xray-core v26.7.11 method alias back into network', () => {
+    const parsed = NetworkSettingsSchema.parse({ method: 'ws', wsSettings: {} });
+    expect((parsed as { network?: string }).network).toBe('ws');
+    expect((parsed as Record<string, unknown>).method).toBeUndefined();
+  });
+
+  it('prefers method over network when both are present', () => {
+    const parsed = NetworkSettingsSchema.parse({ method: 'grpc', network: 'tcp', grpcSettings: {} });
+    expect((parsed as { network?: string }).network).toBe('grpc');
+  });
+});

+ 13 - 1
frontend/vite.config.js

@@ -77,6 +77,18 @@ function injectBasePathPlugin() {
   };
   };
 }
 }
 
 
+// Cloudflare Rocket Loader rewrites script tags and runs bundles through its
+// own loader, breaking ES-module semantics; data-cfasync="false" opts out.
+function rocketLoaderOptOutPlugin() {
+  return {
+    name: 'xui-rocket-loader-opt-out',
+    apply: 'build',
+    transformIndexHtml(html) {
+      return html.replaceAll('<script ', '<script data-cfasync="false" ');
+    },
+  };
+}
+
 function bypassMigratedRoute(req) {
 function bypassMigratedRoute(req) {
   if (req.method !== 'GET') return undefined;
   if (req.method !== 'GET') return undefined;
   const url = req.url.split('?')[0];
   const url = req.url.split('?')[0];
@@ -140,7 +152,7 @@ function makeBackendProxy(target) {
 }
 }
 
 
 export default defineConfig({
 export default defineConfig({
-  plugins: [react(), injectBasePathPlugin()],
+  plugins: [react(), injectBasePathPlugin(), rocketLoaderOptOutPlugin()],
   resolve: {
   resolve: {
     alias: {
     alias: {
       '@': path.resolve(__dirname, 'src'),
       '@': path.resolve(__dirname, 'src'),

+ 6 - 6
go.mod

@@ -21,10 +21,10 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.72.0
 	github.com/valyala/fasthttp v1.72.0
 	github.com/xlzd/gotp v0.1.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12
+	github.com/xtls/xray-core v1.260327.1-0.20260711155151-50231eaff98c
 	go.uber.org/atomic v1.11.0
 	go.uber.org/atomic v1.11.0
-	golang.org/x/crypto v0.53.0
-	golang.org/x/sys v0.46.0
+	golang.org/x/crypto v0.54.0
+	golang.org/x/sys v0.47.0
 	golang.org/x/text v0.40.0
 	golang.org/x/text v0.40.0
 	google.golang.org/grpc v1.82.0
 	google.golang.org/grpc v1.82.0
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
 	gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -64,7 +64,7 @@ require (
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/juju/ratelimit v1.0.2 // indirect
 	github.com/juju/ratelimit v1.0.2 // indirect
 	github.com/klauspost/compress v1.19.0
 	github.com/klauspost/compress v1.19.0
-	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+	github.com/klauspost/cpuid/v2 v2.4.0 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
 	github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
 	github.com/mattn/go-isatty v0.0.22 // indirect
 	github.com/mattn/go-isatty v0.0.22 // indirect
@@ -77,7 +77,7 @@ require (
 	github.com/pion/logging v0.2.4 // indirect
 	github.com/pion/logging v0.2.4 // indirect
 	github.com/pion/stun/v3 v3.1.6 // indirect
 	github.com/pion/stun/v3 v3.1.6 // indirect
 	github.com/pion/transport/v4 v4.0.2 // indirect
 	github.com/pion/transport/v4 v4.0.2 // indirect
-	github.com/pires/go-proxyproto v0.12.0 // indirect
+	github.com/pires/go-proxyproto v0.15.0 // indirect
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/qpack v0.6.0 // indirect
 	github.com/quic-go/quic-go v0.60.0 // indirect
 	github.com/quic-go/quic-go v0.60.0 // indirect
@@ -101,7 +101,7 @@ require (
 	golang.org/x/arch v0.28.0 // indirect
 	golang.org/x/arch v0.28.0 // indirect
 	golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
 	golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
 	golang.org/x/mod v0.37.0 // indirect
 	golang.org/x/mod v0.37.0 // indirect
-	golang.org/x/net v0.56.0
+	golang.org/x/net v0.57.0
 	golang.org/x/sync v0.22.0 // indirect
 	golang.org/x/sync v0.22.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.47.0 // indirect
 	golang.org/x/tools v0.47.0 // indirect

+ 12 - 12
go.sum

@@ -117,8 +117,8 @@ github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
 github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
 github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
 github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
 github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ=
 github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
 github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
-github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
-github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
+github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -156,8 +156,8 @@ github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8=
 github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
 github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
 github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
 github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
 github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
 github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
-github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
-github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
+github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
+github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
 github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
@@ -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/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 h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12 h1:0RZBYuLmkxxlMrSsCNRrdNWHhW5zHTfkC2vAknzZ5P4=
-github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12/go.mod h1:5J11wfaLGhI6HNOi90zVI6E87c1ykA8grPfUeHZYbhA=
+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/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -248,14 +248,14 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs
 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
 golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
 golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
 golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
 golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
-golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
-golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
+golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
+golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
 golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
 golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
 golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
 golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
 golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
 golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
 golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
 golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
-golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
-golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
 golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
 golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
 golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -263,8 +263,8 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
-golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
 golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
 golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
 golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
 golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
 golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=

+ 18 - 0
install.sh

@@ -873,6 +873,24 @@ prompt_and_setup_ssl() {
             # User chose Let's Encrypt IP certificate option
             # User chose Let's Encrypt IP certificate option
             echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
             echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
 
 
+            # Confirm the auto-detected IP before issuing for it: with asymmetric
+            # routing / multi-WAN the echo services can return a transit address.
+            if [[ "$NONINTERACTIVE" != "1" ]]; then
+                local ip_confirm=""
+                read -rp "Is ${server_ip} the correct incoming public IPv4 address for this server? [Default y]: " ip_confirm
+                if [[ -n "$ip_confirm" && "$ip_confirm" != "y" && "$ip_confirm" != "Y" ]]; then
+                    server_ip=""
+                    while [[ -z "$server_ip" ]]; do
+                        read -rp "Please enter your server's public IPv4 address: " server_ip
+                        server_ip="${server_ip// /}"
+                        if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+                            echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
+                            server_ip=""
+                        fi
+                    done
+                fi
+            fi
+
             # Ask for optional IPv6
             # Ask for optional IPv6
             local ipv6_addr=""
             local ipv6_addr=""
             prompt_or_default ipv6_addr "Do you have an IPv6 address to include? (leave empty to skip): " "" XUI_SSL_IPV6
             prompt_or_default ipv6_addr "Do you have an IPv6 address to include? (leave empty to skip): " "" XUI_SSL_IPV6

+ 261 - 1
internal/database/db.go

@@ -91,6 +91,9 @@ func initModels() error {
 			return err
 			return err
 		}
 		}
 	}
 	}
+	if err := dropLegacyInboundPortUnique(); err != nil {
+		return err
+	}
 	if err := migrateHostVerifyPeerCertByNameColumn(); err != nil {
 	if err := migrateHostVerifyPeerCertByNameColumn(); err != nil {
 		return err
 		return err
 	}
 	}
@@ -118,6 +121,12 @@ func initModels() error {
 	if err := migrateLegacySocksInboundsToMixed(); err != nil {
 	if err := migrateLegacySocksInboundsToMixed(); err != nil {
 		return err
 		return err
 	}
 	}
+	if err := migrateShadowsocksRemovedCiphers(); err != nil {
+		return err
+	}
+	if err := migrateVmessRemovedSecurities(); err != nil {
+		return err
+	}
 	if IsPostgres() {
 	if IsPostgres() {
 		if err := resyncPostgresSequences(db, models); err != nil {
 		if err := resyncPostgresSequences(db, models); err != nil {
 			log.Printf("Error resyncing postgres sequences: %v", err)
 			log.Printf("Error resyncing postgres sequences: %v", err)
@@ -160,6 +169,121 @@ func dropLegacyForeignKeys() error {
 	return nil
 	return nil
 }
 }
 
 
+type sqliteIndexListRow struct {
+	Name   string `gorm:"column:name"`
+	Unique int    `gorm:"column:unique"`
+	Origin string `gorm:"column:origin"`
+}
+
+func sqliteUniquePortIndexes() (autoIndexes, explicitIndexes []string, err error) {
+	var list []sqliteIndexListRow
+	if err = db.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
+		return nil, nil, err
+	}
+	for _, idx := range list {
+		if idx.Unique != 1 {
+			continue
+		}
+		var cols []struct {
+			Name string `gorm:"column:name"`
+		}
+		if err = db.Raw(`PRAGMA index_info("` + idx.Name + `")`).Scan(&cols).Error; err != nil {
+			return nil, nil, err
+		}
+		if len(cols) != 1 || cols[0].Name != "port" {
+			continue
+		}
+		if idx.Origin == "c" {
+			explicitIndexes = append(explicitIndexes, idx.Name)
+		} else {
+			autoIndexes = append(autoIndexes, idx.Name)
+		}
+	}
+	return autoIndexes, explicitIndexes, nil
+}
+
+// dropLegacyInboundPortUnique removes the pre-multi-node UNIQUE on inbounds.port,
+// which AutoMigrate never drops and which blocks cross-node port reuse on old SQLite DBs.
+func dropLegacyInboundPortUnique() error {
+	if IsPostgres() {
+		return nil
+	}
+	autoIndexes, explicitIndexes, err := sqliteUniquePortIndexes()
+	if err != nil {
+		return err
+	}
+	for _, name := range explicitIndexes {
+		if err := db.Exec(`DROP INDEX IF EXISTS "` + name + `"`).Error; err != nil {
+			return err
+		}
+	}
+	if len(autoIndexes) == 0 {
+		return nil
+	}
+	log.Printf("Rebuilding inbounds table to drop the legacy UNIQUE constraint on port")
+	return rebuildInboundsWithoutInlineUniquePort()
+}
+
+func sqliteTableColumns(tx *gorm.DB, table string) ([]string, error) {
+	var rows []struct {
+		Name string `gorm:"column:name"`
+	}
+	if err := tx.Raw(`PRAGMA table_info("` + table + `")`).Scan(&rows).Error; err != nil {
+		return nil, err
+	}
+	cols := make([]string, 0, len(rows))
+	for _, r := range rows {
+		cols = append(cols, r.Name)
+	}
+	return cols, nil
+}
+
+func rebuildInboundsWithoutInlineUniquePort() error {
+	return db.Transaction(func(tx *gorm.DB) error {
+		var list []sqliteIndexListRow
+		if err := tx.Raw(`PRAGMA index_list('inbounds')`).Scan(&list).Error; err != nil {
+			return err
+		}
+		for _, idx := range list {
+			if idx.Origin != "c" {
+				continue
+			}
+			if err := tx.Exec(`DROP INDEX IF EXISTS "` + idx.Name + `"`).Error; err != nil {
+				return err
+			}
+		}
+		if err := tx.Exec(`ALTER TABLE inbounds RENAME TO inbounds_legacy_rebuild`).Error; err != nil {
+			return err
+		}
+		if err := tx.Migrator().CreateTable(&model.Inbound{}); err != nil {
+			return err
+		}
+		newCols, err := sqliteTableColumns(tx, "inbounds")
+		if err != nil {
+			return err
+		}
+		oldCols, err := sqliteTableColumns(tx, "inbounds_legacy_rebuild")
+		if err != nil {
+			return err
+		}
+		oldSet := make(map[string]struct{}, len(oldCols))
+		for _, c := range oldCols {
+			oldSet[c] = struct{}{}
+		}
+		shared := make([]string, 0, len(newCols))
+		for _, c := range newCols {
+			if _, ok := oldSet[c]; ok {
+				shared = append(shared, `"`+c+`"`)
+			}
+		}
+		colList := strings.Join(shared, ", ")
+		if err := tx.Exec(`INSERT INTO inbounds (` + colList + `) SELECT ` + colList + ` FROM inbounds_legacy_rebuild`).Error; err != nil {
+			return err
+		}
+		return tx.Exec(`DROP TABLE inbounds_legacy_rebuild`).Error
+	})
+}
+
 func migrateHostVerifyPeerCertByNameColumn() error {
 func migrateHostVerifyPeerCertByNameColumn() error {
 	if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
 	if !db.Migrator().HasColumn(&model.Host{}, "verify_peer_cert_by_name") {
 		return nil
 		return nil
@@ -636,6 +760,125 @@ func migrateLegacySocksInboundsToMixed() error {
 	return nil
 	return nil
 }
 }
 
 
+// migrateShadowsocksRemovedCiphers rewrites shadowsocks inbounds still using
+// the "none"/"plain" ciphers that xray-core v26.7.11 removed; one such row
+// makes the whole generated config unbuildable and keeps xray from starting.
+func migrateShadowsocksRemovedCiphers() error {
+	var inbounds []model.Inbound
+	if err := db.Where("protocol = ?", model.Shadowsocks).Find(&inbounds).Error; err != nil {
+		return err
+	}
+	migrated := int64(0)
+	for _, inbound := range inbounds {
+		if strings.TrimSpace(inbound.Settings) == "" {
+			continue
+		}
+		var settings map[string]any
+		if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+			continue
+		}
+		changed := false
+		if method, _ := settings["method"].(string); method != "" {
+			if replacement, removed := model.ReplaceRemovedShadowsocksCipher(method); removed {
+				settings["method"] = replacement
+				changed = true
+			}
+		}
+		if clients, ok := settings["clients"].([]any); ok {
+			for i := range clients {
+				cm, ok := clients[i].(map[string]any)
+				if !ok {
+					continue
+				}
+				method, _ := cm["method"].(string)
+				if replacement, removed := model.ReplaceRemovedShadowsocksCipher(method); removed {
+					cm["method"] = replacement
+					clients[i] = cm
+					changed = true
+				}
+			}
+		}
+		if !changed {
+			continue
+		}
+		newSettings, err := json.MarshalIndent(settings, "", "  ")
+		if err != nil {
+			log.Printf("migrateShadowsocksRemovedCiphers: skip inbound %d (marshal failed): %v", inbound.Id, err)
+			continue
+		}
+		if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
+			Update("settings", string(newSettings)).Error; err != nil {
+			return err
+		}
+		migrated++
+	}
+	if migrated > 0 {
+		log.Printf("Rewrote removed shadowsocks cipher on %d inbound(s)", migrated)
+	}
+	return nil
+}
+
+// migrateVmessRemovedSecurities rewrites the vmess "none"/"zero" security
+// values that xray-core v26.7.11 removed to "auto" (what the core now treats
+// them as), on both the clients column and each vmess inbound's settings.
+func migrateVmessRemovedSecurities() error {
+	res := db.Exec("UPDATE clients SET security = 'auto' WHERE security IN ('none', 'zero')")
+	if res.Error != nil {
+		log.Printf("Error migrating removed vmess security values on clients: %v", res.Error)
+		return res.Error
+	}
+	if res.RowsAffected > 0 {
+		log.Printf("Migrated %d client(s) off removed vmess security values", res.RowsAffected)
+	}
+	var inbounds []model.Inbound
+	if err := db.Where("protocol = ?", model.VMESS).Find(&inbounds).Error; err != nil {
+		return err
+	}
+	migrated := int64(0)
+	for _, inbound := range inbounds {
+		if strings.TrimSpace(inbound.Settings) == "" {
+			continue
+		}
+		var settings map[string]any
+		if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+			continue
+		}
+		clients, ok := settings["clients"].([]any)
+		if !ok {
+			continue
+		}
+		changed := false
+		for i := range clients {
+			cm, ok := clients[i].(map[string]any)
+			if !ok {
+				continue
+			}
+			if security, _ := cm["security"].(string); security == "none" || security == "zero" {
+				cm["security"] = "auto"
+				clients[i] = cm
+				changed = true
+			}
+		}
+		if !changed {
+			continue
+		}
+		newSettings, err := json.MarshalIndent(settings, "", "  ")
+		if err != nil {
+			log.Printf("migrateVmessRemovedSecurities: skip inbound %d (marshal failed): %v", inbound.Id, err)
+			continue
+		}
+		if err := db.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
+			Update("settings", string(newSettings)).Error; err != nil {
+			return err
+		}
+		migrated++
+	}
+	if migrated > 0 {
+		log.Printf("Rewrote removed vmess security values on %d inbound(s)", migrated)
+	}
+	return nil
+}
+
 // normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
 // normalizeInboundSubSortIndex lifts sub_sort_index values below the 1-based
 // minimum (rows written by builds that defaulted the column to 0, or by nodes
 // minimum (rows written by builds that defaulted the column to 0, or by nodes
 // predating the field) so they cannot sort ahead of explicitly ranked inbounds.
 // predating the field) so they cannot sort ahead of explicitly ranked inbounds.
@@ -810,7 +1053,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 	}
 
 
 	if empty && isUsersEmpty {
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted"}
 		for _, name := range seeders {
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
 				return err
@@ -903,6 +1146,12 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 		}
 	}
 	}
 
 
+	if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
+		if err := seedNodeInboundsAdopted(); err != nil {
+			return err
+		}
+	}
+
 	if err := seedHostsFromExternalProxy(); err != nil {
 	if err := seedHostsFromExternalProxy(); err != nil {
 		return err
 		return err
 	}
 	}
@@ -937,6 +1186,17 @@ func runSeeders(isUsersEmpty bool) error {
 	return normalizeSettingPaths()
 	return normalizeSettingPaths()
 }
 }
 
 
+// seedNodeInboundsAdopted keeps the pre-existing reconcile behavior for nodes
+// that were already syncing before the inbounds_adopted_at gate was introduced.
+func seedNodeInboundsAdopted() error {
+	if err := db.Model(&model.Node{}).
+		Where("inbounds_adopted_at = 0").
+		Update("inbounds_adopted_at", time.Now().Unix()).Error; err != nil {
+		return err
+	}
+	return db.Create(&model.HistoryOfSeeders{SeederName: "NodeInboundsAdopted"}).Error
+}
+
 func seedHostGroupIds() error {
 func seedHostGroupIds() error {
 	var history []string
 	var history []string
 	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
 	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {

+ 76 - 0
internal/database/inbound_port_unique_migration_test.go

@@ -0,0 +1,76 @@
+package database
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+)
+
+const legacyInboundsDDL = "CREATE TABLE `inbounds` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer,`up` integer,`down` integer,`total` integer,`remark` text,`enable` numeric,`expiry_time` integer,`listen` text,`port` integer UNIQUE,`protocol` text,`settings` text,`stream_settings` text,`tag` text UNIQUE,`sniffing` text)"
+
+func openLegacyPortUniqueDB(t *testing.T) string {
+	t.Helper()
+	dbPath := filepath.Join(t.TempDir(), "x-ui.db")
+	legacy, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
+	if err != nil {
+		t.Fatalf("open legacy db: %v", err)
+	}
+	if err := legacy.Exec(legacyInboundsDDL).Error; err != nil {
+		t.Fatalf("create legacy inbounds: %v", err)
+	}
+	if err := legacy.Exec(
+		`INSERT INTO inbounds (user_id, remark, enable, port, protocol, settings, stream_settings, tag, sniffing)
+		 VALUES (1, 'preexisting', 1, 80, 'vless', '{"clients":[]}', '{}', 'in-80-tcp', '{}')`,
+	).Error; err != nil {
+		t.Fatalf("seed legacy inbound: %v", err)
+	}
+	sqlDB, err := legacy.DB()
+	if err != nil {
+		t.Fatalf("legacy db handle: %v", err)
+	}
+	if err := sqlDB.Close(); err != nil {
+		t.Fatalf("close legacy db: %v", err)
+	}
+	return dbPath
+}
+
+func TestDropLegacyInboundPortUnique(t *testing.T) {
+	dbPath := openLegacyPortUniqueDB(t)
+
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("InitDB over legacy schema: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	var preexisting model.Inbound
+	if err := GetDB().Where("tag = ?", "in-80-tcp").First(&preexisting).Error; err != nil {
+		t.Fatalf("preexisting inbound lost in rebuild: %v", err)
+	}
+	if preexisting.Remark != "preexisting" || preexisting.Port != 80 {
+		t.Fatalf("preexisting inbound corrupted: remark=%q port=%d", preexisting.Remark, preexisting.Port)
+	}
+
+	nodeA, nodeB := 1, 2
+	samePortA := &model.Inbound{UserId: 1, Tag: "node-a-80", Enable: true, Port: 80, Protocol: model.VLESS, Remark: "a", Settings: `{"clients":[]}`, NodeID: &nodeA}
+	samePortB := &model.Inbound{UserId: 1, Tag: "node-b-80", Enable: true, Port: 80, Protocol: model.VLESS, Remark: "b", Settings: `{"clients":[]}`, NodeID: &nodeB}
+	if err := GetDB().Create(samePortA).Error; err != nil {
+		t.Fatalf("create node-a inbound on port 80: %v", err)
+	}
+	if err := GetDB().Create(samePortB).Error; err != nil {
+		t.Fatalf("create node-b inbound on port 80: %v", err)
+	}
+
+	var dupTag model.Inbound
+	dupErr := GetDB().Create(&model.Inbound{UserId: 1, Tag: "in-80-tcp", Enable: true, Port: 90, Protocol: model.VLESS, Settings: `{"clients":[]}`}).Error
+	if dupErr == nil {
+		t.Fatalf("duplicate tag insert succeeded, want unique violation; row=%v", dupTag)
+	}
+
+	if err := dropLegacyInboundPortUnique(); err != nil {
+		t.Fatalf("second dropLegacyInboundPortUnique run: %v", err)
+	}
+}

+ 35 - 4
internal/database/model/model.go

@@ -442,8 +442,23 @@ func StripVlessInboundEncryption(settings string) (string, bool) {
 	return string(out), true
 	return string(out), true
 }
 }
 
 
-// HealShadowsocksClientMethods normalises the per-client `method` field
-// on a shadowsocks inbound's settings JSON before it leaves for xray-core:
+// ReplaceRemovedShadowsocksCipher maps ciphers that xray-core v26.7.11
+// deleted ("none"/"plain" make the whole config fail with "unknown cipher
+// method") to a still-supported replacement. Returns the replacement and
+// true when the given method is one of the removed ciphers.
+func ReplaceRemovedShadowsocksCipher(method string) (string, bool) {
+	switch method {
+	case "none", "plain":
+		return "chacha20-ietf-poly1305", true
+	}
+	return method, false
+}
+
+// HealShadowsocksClientMethods normalises the `method` fields on a
+// shadowsocks inbound's settings JSON before it leaves for xray-core:
+//   - Ciphers removed upstream (none/plain): rewritten via
+//     ReplaceRemovedShadowsocksCipher so one legacy row cannot prevent
+//     xray from starting.
 //   - Legacy ciphers (aes-*, chacha20-*): every client must carry a
 //   - Legacy ciphers (aes-*, chacha20-*): every client must carry a
 //     per-user `method` matching the inbound's top-level method, otherwise
 //     per-user `method` matching the inbound's top-level method, otherwise
 //     xray fails with "unsupported cipher method:".
 //     xray fails with "unsupported cipher method:".
@@ -462,12 +477,24 @@ func HealShadowsocksClientMethods(settings string) (string, bool) {
 		return settings, false
 		return settings, false
 	}
 	}
 	method, _ := parsed["method"].(string)
 	method, _ := parsed["method"].(string)
+	changed := false
+	if replacement, removed := ReplaceRemovedShadowsocksCipher(method); removed {
+		method = replacement
+		parsed["method"] = method
+		changed = true
+	}
 	clients, ok := parsed["clients"].([]any)
 	clients, ok := parsed["clients"].([]any)
 	if !ok {
 	if !ok {
-		return settings, false
+		if !changed {
+			return settings, false
+		}
+		out, err := json.MarshalIndent(parsed, "", "  ")
+		if err != nil {
+			return settings, false
+		}
+		return string(out), true
 	}
 	}
 	is2022 := strings.HasPrefix(method, "2022-blake3-")
 	is2022 := strings.HasPrefix(method, "2022-blake3-")
-	changed := false
 	for i := range clients {
 	for i := range clients {
 		cm, ok := clients[i].(map[string]any)
 		cm, ok := clients[i].(map[string]any)
 		if !ok {
 		if !ok {
@@ -719,6 +746,10 @@ type Node struct {
 	ConfigDirty   bool  `json:"configDirty" gorm:"default:false"`
 	ConfigDirty   bool  `json:"configDirty" gorm:"default:false"`
 	ConfigDirtyAt int64 `json:"configDirtyAt"`
 	ConfigDirtyAt int64 `json:"configDirtyAt"`
 
 
+	// InboundsAdoptedAt records the first clean traffic sync that imported the
+	// node's pre-existing inbounds; reconcile must not sweep remote tags before it.
+	InboundsAdoptedAt int64 `json:"-" gorm:"column:inbounds_adopted_at;default:0"`
+
 	InboundCount  int `json:"inboundCount" gorm:"-" example:"5"`
 	InboundCount  int `json:"inboundCount" gorm:"-" example:"5"`
 	ClientCount   int `json:"clientCount" gorm:"-" example:"27"`
 	ClientCount   int `json:"clientCount" gorm:"-" example:"27"`
 	OnlineCount   int `json:"onlineCount" gorm:"-" example:"3"`
 	OnlineCount   int `json:"onlineCount" gorm:"-" example:"3"`

+ 43 - 0
internal/database/model/model_shadowsocks_removed_test.go

@@ -0,0 +1,43 @@
+package model
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+// TestHealShadowsocksClientMethods_RewritesRemovedCipher covers the last-gate
+// build-time heal for xray-core v26.7.11's removed "none"/"plain" ciphers: a
+// row that survives to config generation (restored backup, direct DB edit)
+// must be rewritten to a supported cipher on both the inbound method and its
+// clients so one such inbound cannot keep xray from starting.
+func TestHealShadowsocksClientMethods_RewritesRemovedCipher(t *testing.T) {
+	settings := `{"method": "plain", "clients": [{"email": "a@x", "password": "p", "method": "plain"}]}`
+	healed, ok := HealShadowsocksClientMethods(settings)
+	if !ok {
+		t.Fatal("expected heal to report a change for a removed cipher")
+	}
+	var parsed struct {
+		Method  string           `json:"method"`
+		Clients []map[string]any `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(healed), &parsed); err != nil {
+		t.Fatalf("parse healed settings: %v", err)
+	}
+	if parsed.Method != "chacha20-ietf-poly1305" {
+		t.Fatalf("expected inbound method rewritten to a supported cipher, got %q", parsed.Method)
+	}
+	if parsed.Clients[0]["method"] != "chacha20-ietf-poly1305" {
+		t.Fatalf("expected client method to match the healed cipher, got %v", parsed.Clients[0]["method"])
+	}
+}
+
+func TestReplaceRemovedShadowsocksCipher(t *testing.T) {
+	for _, method := range []string{"none", "plain"} {
+		if got, removed := ReplaceRemovedShadowsocksCipher(method); !removed || got != "chacha20-ietf-poly1305" {
+			t.Fatalf("ReplaceRemovedShadowsocksCipher(%q) = (%q, %v), want a supported replacement", method, got, removed)
+		}
+	}
+	if got, removed := ReplaceRemovedShadowsocksCipher("aes-256-gcm"); removed || got != "aes-256-gcm" {
+		t.Fatalf("ReplaceRemovedShadowsocksCipher(aes-256-gcm) = (%q, %v), want it left untouched", got, removed)
+	}
+}

+ 117 - 0
internal/database/removed_cipher_migration_test.go

@@ -0,0 +1,117 @@
+package database
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestMigrateShadowsocksRemovedCiphers_RewritesNoneAndPlain covers the
+// xray-core v26.7.11 removal of the shadowsocks "none"/"plain" ciphers: one
+// such row makes the generated config unbuildable, so startup must rewrite
+// both the inbound method and any per-client method to a supported cipher and
+// leave a valid inbound untouched.
+func TestMigrateShadowsocksRemovedCiphers_RewritesNoneAndPlain(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB failed: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	removed := `{"method": "none", "clients": [{"email": "a@x", "password": "p", "method": "plain"}]}`
+	dirty := model.Inbound{UserId: 1, Port: 31001, Protocol: model.Shadowsocks, Tag: "ss-removed", Settings: removed}
+	if err := db.Create(&dirty).Error; err != nil {
+		t.Fatalf("create dirty inbound: %v", err)
+	}
+
+	valid := `{"method": "aes-256-gcm", "clients": [{"email": "b@x", "password": "p"}]}`
+	clean := model.Inbound{UserId: 1, Port: 31002, Protocol: model.Shadowsocks, Tag: "ss-valid", Settings: valid}
+	if err := db.Create(&clean).Error; err != nil {
+		t.Fatalf("create clean inbound: %v", err)
+	}
+
+	if err := migrateShadowsocksRemovedCiphers(); err != nil {
+		t.Fatalf("migrateShadowsocksRemovedCiphers: %v", err)
+	}
+
+	var gotDirty model.Inbound
+	if err := db.First(&gotDirty, dirty.Id).Error; err != nil {
+		t.Fatalf("reload dirty inbound: %v", err)
+	}
+	var parsed struct {
+		Method  string           `json:"method"`
+		Clients []map[string]any `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(gotDirty.Settings), &parsed); err != nil {
+		t.Fatalf("parse repaired settings: %v", err)
+	}
+	if parsed.Method != "chacha20-ietf-poly1305" {
+		t.Fatalf("expected inbound method rewritten, got %q", parsed.Method)
+	}
+	if parsed.Clients[0]["method"] != "chacha20-ietf-poly1305" {
+		t.Fatalf("expected client method rewritten, got %v", parsed.Clients[0]["method"])
+	}
+
+	var gotClean model.Inbound
+	if err := db.First(&gotClean, clean.Id).Error; err != nil {
+		t.Fatalf("reload clean inbound: %v", err)
+	}
+	if gotClean.Settings != valid {
+		t.Fatalf("valid inbound was rewritten:\nbefore: %s\nafter:  %s", valid, gotClean.Settings)
+	}
+}
+
+// TestMigrateVmessRemovedSecurities_RewritesNoneAndZero covers the v26.7.11
+// removal of vmess "none"/"zero" security values: startup rewrites them to
+// "auto" on both the clients column and each vmess inbound's settings JSON.
+func TestMigrateVmessRemovedSecurities_RewritesNoneAndZero(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB failed: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	settings := `{"clients": [{"id": "u1", "email": "a@x", "security": "none"},` +
+		`{"id": "u2", "email": "b@x", "security": "zero"},` +
+		`{"id": "u3", "email": "c@x", "security": "aes-128-gcm"}]}`
+	inbound := model.Inbound{UserId: 1, Port: 32001, Protocol: model.VMESS, Tag: "vmess-removed", Settings: settings}
+	if err := db.Create(&inbound).Error; err != nil {
+		t.Fatalf("create vmess inbound: %v", err)
+	}
+	if err := db.Create(&model.ClientRecord{Email: "a@x", Security: "zero", Enable: true}).Error; err != nil {
+		t.Fatalf("create client record: %v", err)
+	}
+
+	if err := migrateVmessRemovedSecurities(); err != nil {
+		t.Fatalf("migrateVmessRemovedSecurities: %v", err)
+	}
+
+	var got model.Inbound
+	if err := db.First(&got, inbound.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	var parsed struct {
+		Clients []map[string]any `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(got.Settings), &parsed); err != nil {
+		t.Fatalf("parse settings: %v", err)
+	}
+	if parsed.Clients[0]["security"] != "auto" || parsed.Clients[1]["security"] != "auto" {
+		t.Fatalf("expected removed securities rewritten to auto, got %v", parsed.Clients)
+	}
+	if parsed.Clients[2]["security"] != "aes-128-gcm" {
+		t.Fatalf("expected valid security untouched, got %v", parsed.Clients[2]["security"])
+	}
+
+	var rec model.ClientRecord
+	if err := db.Where("email = ?", "a@x").First(&rec).Error; err != nil {
+		t.Fatalf("reload client record: %v", err)
+	}
+	if rec.Security != "auto" {
+		t.Fatalf("expected client record security rewritten to auto, got %q", rec.Security)
+	}
+}

+ 1 - 5
internal/sub/clash_service.go

@@ -236,11 +236,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 		proxy["type"] = "vmess"
 		proxy["type"] = "vmess"
 		proxy["uuid"] = client.ID
 		proxy["uuid"] = client.ID
 		proxy["alterId"] = 0
 		proxy["alterId"] = 0
-		cipher := client.Security
-		if cipher == "" {
-			cipher = "auto"
-		}
-		proxy["cipher"] = cipher
+		proxy["cipher"] = normalizeVmessSecurity(client.Security)
 	case model.VLESS:
 	case model.VLESS:
 		proxy["type"] = "vless"
 		proxy["type"] = "vless"
 		proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
 		proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))

+ 1 - 4
internal/sub/json_service.go

@@ -384,10 +384,7 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
 	}
 	}
 	outbound.StreamSettings = streamSettings
 	outbound.StreamSettings = streamSettings
 
 
-	security := client.Security
-	if security == "" {
-		security = "auto"
-	}
+	security := normalizeVmessSecurity(client.Security)
 	outbound.Settings = map[string]any{
 	outbound.Settings = map[string]any{
 		"address":  inbound.Listen,
 		"address":  inbound.Listen,
 		"port":     inbound.Port,
 		"port":     inbound.Port,

+ 14 - 1
internal/sub/service.go

@@ -714,7 +714,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
 		return ""
 		return ""
 	}
 	}
 	obj["id"] = client.ID
 	obj["id"] = client.ID
-	obj["scy"] = client.Security
+	obj["scy"] = normalizeVmessSecurity(client.Security)
 
 
 	externalProxies, _ := stream["externalProxy"].([]any)
 	externalProxies, _ := stream["externalProxy"].([]any)
 
 
@@ -726,6 +726,18 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
 	return buildVmessLink(obj)
 	return buildVmessLink(obj)
 }
 }
 
 
+// normalizeVmessSecurity maps the vmess security values xray-core v26.7.11
+// removed ("none"/"zero"), plus the legacy empty string, to "auto" so links
+// and subscriptions stop advertising values the upgraded server rejects on
+// the wire.
+func normalizeVmessSecurity(security string) string {
+	switch security {
+	case "", "none", "zero":
+		return "auto"
+	}
+	return security
+}
+
 // vlessEncryptionEnabled reports whether the VLESS inbound settings enable
 // vlessEncryptionEnabled reports whether the VLESS inbound settings enable
 // VLESS-level encryption (vlessenc / ML-KEM). When on, the encryption/decryption
 // VLESS-level encryption (vlessenc / ML-KEM). When on, the encryption/decryption
 // fields hold a generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>");
 // fields hold a generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>");
@@ -2131,6 +2143,7 @@ var validFinalMaskTCPTypes = map[string]struct{}{
 	"header-custom": {},
 	"header-custom": {},
 	"fragment":      {},
 	"fragment":      {},
 	"sudoku":        {},
 	"sudoku":        {},
+	"xmc":           {},
 }
 }
 
 
 // applyKcpShareParams reconstructs legacy KCP share-link fields from either
 // applyKcpShareParams reconstructs legacy KCP share-link fields from either

+ 15 - 0
internal/sub/service_test.go

@@ -1016,6 +1016,21 @@ func TestMarshalFinalMask_UnknownTypeIsDropped(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestMarshalFinalMask_KeepsXmcTcpMask(t *testing.T) {
+	fm := map[string]any{
+		"tcp": []any{
+			map[string]any{"type": "xmc", "settings": map[string]any{"password": "p"}},
+		},
+	}
+	out, ok := marshalFinalMask(fm)
+	if !ok {
+		t.Fatal("expected ok=true for an xmc tcp mask")
+	}
+	if !strings.Contains(out, "xmc") {
+		t.Fatalf("marshaled finalmask dropped the xmc mask: %s", out)
+	}
+}
+
 func TestHasFinalMaskContent(t *testing.T) {
 func TestHasFinalMaskContent(t *testing.T) {
 	if hasFinalMaskContent(nil) {
 	if hasFinalMaskContent(nil) {
 		t.Fatal("nil should not count as content")
 		t.Fatal("nil should not count as content")

+ 17 - 4
internal/util/link/outbound.go

@@ -208,6 +208,10 @@ func parseVmess(link string) (*ParseResult, error) {
 	}
 	}
 
 
 	port := num(j["port"])
 	port := num(j["port"])
+	scy := getString(j, "scy", "auto")
+	if scy == "none" || scy == "zero" {
+		scy = "auto"
+	}
 	ob := Outbound{
 	ob := Outbound{
 		"protocol": "vmess",
 		"protocol": "vmess",
 		"tag":      getString(j, "ps", ""),
 		"tag":      getString(j, "ps", ""),
@@ -219,7 +223,7 @@ func parseVmess(link string) (*ParseResult, error) {
 					"users": []any{
 					"users": []any{
 						map[string]any{
 						map[string]any{
 							"id":       getString(j, "id", ""),
 							"id":       getString(j, "id", ""),
-							"security": getString(j, "scy", "auto"),
+							"security": scy,
 						},
 						},
 					},
 					},
 				},
 				},
@@ -338,12 +342,15 @@ func parseShadowsocks(link string) (*ParseResult, error) {
 		remark, _ = url.QueryUnescape(link[i+1:])
 		remark, _ = url.QueryUnescape(link[i+1:])
 		link = link[:i]
 		link = link[:i]
 	}
 	}
+	if i := strings.Index(link, "?"); i >= 0 {
+		link = link[:i]
+	}
 	core := strings.TrimPrefix(link, "ss://")
 	core := strings.TrimPrefix(link, "ss://")
 	at := strings.Index(core, "@")
 	at := strings.Index(core, "@")
 	if at >= 0 {
 	if at >= 0 {
 		// modern
 		// modern
 		userB64 := core[:at]
 		userB64 := core[:at]
-		hp := core[at+1:]
+		hp := strings.TrimRight(core[at+1:], "/")
 		userInfo, err := base64DecodeFlexible(userB64)
 		userInfo, err := base64DecodeFlexible(userB64)
 		if err != nil {
 		if err != nil {
 			// SIP022 (2022-blake3-*) userinfo is percent-encoded, not base64.
 			// SIP022 (2022-blake3-*) userinfo is percent-encoded, not base64.
@@ -358,7 +365,10 @@ func parseShadowsocks(link string) (*ParseResult, error) {
 			return nil, fmt.Errorf("bad ss host:port")
 			return nil, fmt.Errorf("bad ss host:port")
 		}
 		}
 		host := hp[:colon]
 		host := hp[:colon]
-		port, _ := strconv.Atoi(hp[colon+1:])
+		port, err := strconv.Atoi(hp[colon+1:])
+		if err != nil {
+			return nil, fmt.Errorf("bad ss port %q: %w", hp[colon+1:], err)
+		}
 		method, pass := splitMethodPass(userInfo)
 		method, pass := splitMethodPass(userInfo)
 		identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
 		identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
 		ob := Outbound{
 		ob := Outbound{
@@ -388,7 +398,10 @@ func parseShadowsocks(link string) (*ParseResult, error) {
 		return nil, fmt.Errorf("bad legacy ss hp")
 		return nil, fmt.Errorf("bad legacy ss hp")
 	}
 	}
 	host := hp[:colon]
 	host := hp[:colon]
-	port, _ := strconv.Atoi(hp[colon+1:])
+	port, err := strconv.Atoi(hp[colon+1:])
+	if err != nil {
+		return nil, fmt.Errorf("bad legacy ss port %q: %w", hp[colon+1:], err)
+	}
 	method, pass := splitMethodPass(userInfo)
 	method, pass := splitMethodPass(userInfo)
 	identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
 	identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
 	ob := Outbound{
 	ob := Outbound{

+ 110 - 0
internal/util/link/outbound_test.go

@@ -1,6 +1,7 @@
 package link
 package link
 
 
 import (
 import (
+	"encoding/base64"
 	"net/url"
 	"net/url"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
@@ -113,6 +114,115 @@ func TestSanitizeFinalMaskQuicParams_ClampsAndRejects(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestParseShadowsocks(t *testing.T) {
+	modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
+	legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:[email protected]:8388"))
+	cases := []struct {
+		name   string
+		link   string
+		host   string
+		port   int
+		method string
+		pass   string
+	}{
+		{
+			name:   "modern",
+			link:   "ss://" + modernUser + "@1.2.3.4:8388#node",
+			host:   "1.2.3.4",
+			port:   8388,
+			method: "aes-256-gcm",
+			pass:   "secretpass",
+		},
+		{
+			name:   "modern with plugin query",
+			link:   "ss://" + modernUser + "@1.2.3.4:8388?plugin=v2ray-plugin#node",
+			host:   "1.2.3.4",
+			port:   8388,
+			method: "aes-256-gcm",
+			pass:   "secretpass",
+		},
+		{
+			name:   "modern sip002 slash query",
+			link:   "ss://" + modernUser + "@1.2.3.4:8388/?plugin=obfs-local%3Bobfs%3Dhttp#node",
+			host:   "1.2.3.4",
+			port:   8388,
+			method: "aes-256-gcm",
+			pass:   "secretpass",
+		},
+		{
+			name:   "legacy",
+			link:   "ss://" + legacyBody + "#node",
+			host:   "1.2.3.4",
+			port:   8388,
+			method: "aes-256-gcm",
+			pass:   "secretpass",
+		},
+		{
+			name:   "base64url userinfo with plugin and trailing slash",
+			link:   "ss://" + base64.RawURLEncoding.EncodeToString([]byte("aes-128-gcm:pa+ss/word")) + "@1.2.3.4:8388/?plugin=obfs-local%3Bobfs%3Dhttp#node",
+			host:   "1.2.3.4",
+			port:   8388,
+			method: "aes-128-gcm",
+			pass:   "pa+ss/word",
+		},
+		{
+			name:   "sip022 percent-encoded userinfo",
+			link:   "ss://2022-blake3-aes-256-gcm:YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%[email protected]:8888#Example3",
+			host:   "example.com",
+			port:   8888,
+			method: "2022-blake3-aes-256-gcm",
+			pass:   "YctPZ6U7xPPcU+gp3u+0tx/tRizJN9K8y+uKlW2qjlI=",
+		},
+		{
+			name:   "sip022 dual-key password with type query preserves inner colon",
+			link:   "ss://2022-blake3-aes-256-gcm:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB%[email protected]:9999?type=tcp#node",
+			host:   "1.2.3.4",
+			port:   9999,
+			method: "2022-blake3-aes-256-gcm",
+			pass:   "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			res, err := ParseLink(c.link)
+			if err != nil {
+				t.Fatalf("parse ss: %v", err)
+			}
+			if res.Outbound["protocol"] != "shadowsocks" {
+				t.Fatalf("protocol = %v, want shadowsocks", res.Outbound["protocol"])
+			}
+			srv := res.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
+			if srv["address"] != c.host {
+				t.Errorf("address = %v, want %v", srv["address"], c.host)
+			}
+			if srv["port"] != c.port {
+				t.Errorf("port = %v, want %v", srv["port"], c.port)
+			}
+			if srv["method"] != c.method {
+				t.Errorf("method = %v, want %v", srv["method"], c.method)
+			}
+			if srv["password"] != c.pass {
+				t.Errorf("password = %v, want %v", srv["password"], c.pass)
+			}
+		})
+	}
+}
+
+func TestParseShadowsocksBadPort(t *testing.T) {
+	user := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
+	cases := map[string]string{
+		"modern": "ss://" + user + "@1.2.3.4:notaport#node",
+		"legacy": "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:[email protected]:notaport")) + "#node",
+	}
+	for name, link := range cases {
+		t.Run(name, func(t *testing.T) {
+			if _, err := ParseLink(link); err == nil {
+				t.Errorf("expected parse error for non-numeric port, got nil")
+			}
+		})
+	}
+}
+
 func TestParseSubscriptionBody_Base64(t *testing.T) {
 func TestParseSubscriptionBody_Base64(t *testing.T) {
 	// base64 of the two joined links:
 	// base64 of the two joined links:
 	// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
 	// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B

+ 1 - 1
internal/web/controller/dist.go

@@ -110,7 +110,7 @@ func serveDistPage(c *gin.Context, name string) {
 	if nonce := c.GetString("csp_nonce"); nonce != "" {
 	if nonce := c.GetString("csp_nonce"); nonce != "" {
 		nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
 		nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
 	}
 	}
-	script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
+	script := `<script data-cfasync="false"` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
 	if name != "login.html" {
 	if name != "login.html" {
 		escapedVer := jsEscape.Replace(config.GetPanelVersion())
 		escapedVer := jsEscape.Replace(config.GetPanelVersion())
 		script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
 		script += `;window.X_UI_CUR_VER="` + escapedVer + `"`

+ 68 - 0
internal/web/job/check_client_ip_frozen_ban_test.go

@@ -0,0 +1,68 @@
+package job
+
+import (
+	"os"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+func banLineCount(t *testing.T, email string) int {
+	t.Helper()
+	body, err := os.ReadFile(readIpLimitLogPath())
+	if err != nil {
+		if os.IsNotExist(err) {
+			return 0
+		}
+		t.Fatalf("read 3xipl.log: %v", err)
+	}
+	return strings.Count(string(body), "[LIMIT_IP] Email = "+email)
+}
+
+func TestUpdateInboundClientIps_FrozenLastSeenBannedOnce(t *testing.T) {
+	setupIntegrationDB(t)
+
+	const email = "frozen-ban"
+	seedInboundWithClient(t, "inbound-frozen-ban", email, 1)
+
+	now := time.Now().Unix()
+	deadStart := now - 300
+	live := []IPWithTimestamp{
+		{IP: "10.2.0.1", Timestamp: deadStart},
+		{IP: "192.0.2.7", Timestamp: now},
+	}
+
+	j := NewCheckClientIpJob()
+	inbound, err := j.getInboundByEmail(email)
+	if err != nil {
+		t.Fatalf("getInboundByEmail: %v", err)
+	}
+	row := seedClientIps(t, email, nil)
+
+	if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); !banned {
+		t.Fatalf("first scan: the over-limit stale IP must be banned")
+	}
+	if got := banLineCount(t, email); got != 1 {
+		t.Fatalf("ban lines after first scan = %d, want 1", got)
+	}
+
+	if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, true); banned {
+		t.Fatalf("second scan with a frozen lastSeen must not re-ban a dead connection")
+	}
+	if got := banLineCount(t, email); got != 1 {
+		t.Fatalf("ban lines after frozen rescan = %d, want still 1", got)
+	}
+
+	reconnected := []IPWithTimestamp{
+		{IP: "10.2.0.1", Timestamp: now + 30},
+		{IP: "192.0.2.7", Timestamp: now + 60},
+	}
+	if _, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, reconnected, true, true); !banned {
+		t.Fatalf("a reconnect (advanced lastSeen) must be banned again")
+	}
+	if got := banLineCount(t, email); got != 2 {
+		t.Fatalf("ban lines after reconnect = %d, want 2", got)
+	}
+}

+ 34 - 2
internal/web/job/check_client_ip_job.go

@@ -9,6 +9,7 @@ import (
 	"os/exec"
 	"os/exec"
 	"runtime"
 	"runtime"
 	"sort"
 	"sort"
+	"strings"
 	"time"
 	"time"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -32,6 +33,7 @@ type IPWithTimestamp struct {
 // simply skips the run (the bundled core always supports it).
 // simply skips the run (the bundled core always supports it).
 type CheckClientIpJob struct {
 type CheckClientIpJob struct {
 	disAllowedIps []string
 	disAllowedIps []string
+	bannedSeen    map[string]int64
 	xrayService   service.XrayService
 	xrayService   service.XrayService
 }
 }
 
 
@@ -509,7 +511,8 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
 
 
 	// historical db-only ips are excluded from this count on purpose.
 	// historical db-only ips are excluded from this count on purpose.
 	keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
 	keptLive, bannedLive := selectIpsToBan(liveIps, limitIp)
-	if len(bannedLive) > 0 {
+	actionable := j.filterAdvancedSinceLastBan(clientEmail, bannedLive)
+	if len(actionable) > 0 {
 		shouldCleanLog = true
 		shouldCleanLog = true
 		banned = true
 		banned = true
 
 
@@ -525,7 +528,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
 		// filter.d/3x-ipl.conf with
 		// filter.d/3x-ipl.conf with
 		//   failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
 		//   failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
 		// don't change the wording.
 		// don't change the wording.
-		for _, ipTime := range bannedLive {
+		for _, ipTime := range actionable {
 			j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
 			j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
 			ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
 			ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
 		}
 		}
@@ -552,6 +555,35 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
 	return shouldCleanLog, banned
 	return shouldCleanLog, banned
 }
 }
 
 
+// filterAdvancedSinceLastBan keeps only banned pairs whose lastSeen advanced since
+// the previous ban: the core refreshes lastSeen solely on a new dispatch, so a
+// frozen value is a dead connection it hasn't reaped yet, not a reconnect.
+func (j *CheckClientIpJob) filterAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp {
+	if j.bannedSeen == nil {
+		j.bannedSeen = make(map[string]int64)
+	}
+	current := make(map[string]struct{}, len(banned))
+	actionable := make([]IPWithTimestamp, 0, len(banned))
+	for _, ipTime := range banned {
+		key := email + "|" + ipTime.IP
+		current[key] = struct{}{}
+		if last, ok := j.bannedSeen[key]; ok && ipTime.Timestamp <= last {
+			continue
+		}
+		j.bannedSeen[key] = ipTime.Timestamp
+		actionable = append(actionable, ipTime)
+	}
+	prefix := email + "|"
+	for key := range j.bannedSeen {
+		if strings.HasPrefix(key, prefix) {
+			if _, still := current[key]; !still {
+				delete(j.bannedSeen, key)
+			}
+		}
+	}
+	return actionable
+}
+
 // disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
 // disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
 func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
 func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
 	var xrayAPI xray.XrayAPI
 	var xrayAPI xray.XrayAPI

+ 19 - 0
internal/web/job/node_traffic_sync_job.go

@@ -391,6 +391,20 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 	}
 	}
 	service.FilterNodeSnapshot(n, snap)
 	service.FilterNodeSnapshot(n, snap)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
+	if !dirty {
+		if pending, checkErr := j.inboundService.SnapshotHasUnadoptedInbounds(n.Id, snap); checkErr != nil {
+			logger.Warningf("node traffic sync: unadopted-inbound check for %s failed: %v", n.Name, checkErr)
+		} else if pending {
+			hostCtx, hostCancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
+			groups, hgErr := rt.FetchHostGroups(hostCtx)
+			hostCancel()
+			if hgErr != nil {
+				logger.Debugf("node traffic sync: fetch host groups from %s failed: %v", n.Name, hgErr)
+			} else {
+				snap.HostGroups = groups
+			}
+		}
+	}
 	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
 	changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
 	if err != nil {
 	if err != nil {
 		logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
 		logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err)
@@ -399,6 +413,11 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 	if changed {
 	if changed {
 		j.structural.set()
 		j.structural.set()
 	}
 	}
+	if !dirty && n.InboundsAdoptedAt == 0 {
+		if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil {
+			logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr)
+		}
+	}
 
 
 	active := make([]string, 0, len(snap.OnlineEmails))
 	active := make([]string, 0, len(snap.OnlineEmails))
 	active = append(active, snap.OnlineEmails...)
 	active = append(active, snap.OnlineEmails...)

+ 20 - 0
internal/web/runtime/remote.go

@@ -21,6 +21,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 )
 
 
@@ -669,6 +670,25 @@ type TrafficSnapshot struct {
 	// the per-GUID endpoint — OnlineEmails is the fallback then.
 	// the per-GUID endpoint — OnlineEmails is the fallback then.
 	OnlineTree    map[string][]string
 	OnlineTree    map[string][]string
 	LastOnlineMap map[string]int64
 	LastOnlineMap map[string]int64
+	// HostGroups carries the node's per-inbound host overrides (TLS/SNI/
+	// fingerprint), fetched only when the snapshot holds a not-yet-adopted tag.
+	HostGroups []*entity.HostGroup
+}
+
+// FetchHostGroups pulls the node's host overrides so a freshly adopted inbound
+// keeps its subscription TLS/SNI/fingerprint settings on the master.
+func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, error) {
+	env, err := r.do(ctx, http.MethodGet, "panel/api/hosts/list", nil)
+	if err != nil {
+		return nil, err
+	}
+	var groups []*entity.HostGroup
+	if len(env.Obj) > 0 {
+		if err := json.Unmarshal(env.Obj, &groups); err != nil {
+			return nil, fmt.Errorf("decode host groups: %w", err)
+		}
+	}
+	return groups, nil
 }
 }
 
 
 func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) {
 func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) {

+ 20 - 6
internal/web/service/client_bulk.go

@@ -1208,7 +1208,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 
 
 	db := database.GetDB()
 	db := database.GetDB()
 	const lookupChunk = 400
 	const lookupChunk = 400
-	existingEmailSub := make(map[string]string, len(emails))
+	existingByEmail := make(map[string]model.ClientRecord, len(emails))
 	for start := 0; start < len(emails); start += lookupChunk {
 	for start := 0; start < len(emails); start += lookupChunk {
 		end := min(start+lookupChunk, len(emails))
 		end := min(start+lookupChunk, len(emails))
 		var rows []model.ClientRecord
 		var rows []model.ClientRecord
@@ -1216,7 +1216,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 			return result, false, e
 			return result, false, e
 		}
 		}
 		for i := range rows {
 		for i := range rows {
-			existingEmailSub[strings.ToLower(rows[i].Email)] = rows[i].SubID
+			existingByEmail[strings.ToLower(rows[i].Email)] = rows[i]
 		}
 		}
 	}
 	}
 	existingSubOwner := make(map[string]string, len(subIDs))
 	existingSubOwner := make(map[string]string, len(subIDs))
@@ -1252,10 +1252,24 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 
 
 	for idx := range prep {
 	for idx := range prep {
 		le := strings.ToLower(prep[idx].client.Email)
 		le := strings.ToLower(prep[idx].client.Email)
-		if existSub, ok := existingEmailSub[le]; ok && existSub != prep[idx].client.SubID {
-			failed[idx] = true
-			reason[idx] = "email already in use: " + prep[idx].client.Email
-			continue
+		if rec, ok := existingByEmail[le]; ok {
+			if rec.SubID != prep[idx].client.SubID {
+				failed[idx] = true
+				reason[idx] = "email already in use: " + prep[idx].client.Email
+				continue
+			}
+			if prep[idx].client.ID == "" {
+				prep[idx].client.ID = rec.UUID
+			}
+			if prep[idx].client.Password == "" {
+				prep[idx].client.Password = rec.Password
+			}
+			if prep[idx].client.Auth == "" {
+				prep[idx].client.Auth = rec.Auth
+			}
+			if prep[idx].client.Secret == "" {
+				prep[idx].client.Secret = rec.Secret
+			}
 		}
 		}
 		if owner, ok := existingSubOwner[prep[idx].client.SubID]; ok && owner != le {
 		if owner, ok := existingSubOwner[prep[idx].client.SubID]; ok && owner != le {
 			failed[idx] = true
 			failed[idx] = true

+ 54 - 0
internal/web/service/client_create_reuse_test.go

@@ -0,0 +1,54 @@
+package service
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func settingsHoldUUID(t *testing.T, inboundSvc *InboundService, inboundId int, uuid string) bool {
+	t.Helper()
+	ib, err := inboundSvc.GetInbound(inboundId)
+	if err != nil {
+		t.Fatalf("GetInbound %d: %v", inboundId, err)
+	}
+	return strings.Contains(ib.Settings, uuid)
+}
+
+func TestCreateRepeatKeepsExistingUUID(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	ibA := mkInbound(t, 21001, model.VLESS, `{"clients":[]}`)
+	ibB := mkInbound(t, 21002, model.VLESS, `{"clients":[]}`)
+
+	const originalUUID = "aaaaaaaa-1111-2222-3333-444444444444"
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client:     model.Client{Email: "repeat@x", ID: originalUUID, SubID: "sub-repeat", Enable: true},
+		InboundIds: []int{ibA.Id},
+	}); err != nil {
+		t.Fatalf("first Create: %v", err)
+	}
+	if rec := lookupClientRecord(t, "repeat@x"); rec.UUID != originalUUID {
+		t.Fatalf("record UUID after first Create = %q, want %q", rec.UUID, originalUUID)
+	}
+
+	if _, err := svc.Create(inboundSvc, &ClientCreatePayload{
+		Client:     model.Client{Email: "repeat@x", SubID: "sub-repeat", Enable: true},
+		InboundIds: []int{ibB.Id},
+	}); err != nil {
+		t.Fatalf("repeat Create: %v", err)
+	}
+
+	if rec := lookupClientRecord(t, "repeat@x"); rec.UUID != originalUUID {
+		t.Fatalf("record UUID after repeat Create = %q, want %q", rec.UUID, originalUUID)
+	}
+	if !settingsHoldUUID(t, inboundSvc, ibA.Id, originalUUID) {
+		t.Fatalf("inbound A settings lost the original UUID")
+	}
+	if !settingsHoldUUID(t, inboundSvc, ibB.Id, originalUUID) {
+		t.Fatalf("inbound B settings did not reuse the original UUID")
+	}
+}

+ 43 - 8
internal/web/service/client_crud.go

@@ -82,6 +82,20 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 		if existing.SubID == "" || existing.SubID != client.SubID {
 		if existing.SubID == "" || existing.SubID != client.SubID {
 			return false, common.NewError("email already in use:", client.Email)
 			return false, common.NewError("email already in use:", client.Email)
 		}
 		}
+		// Reuse stored credentials when re-adding an existing identity, or
+		// fillProtocolDefaults mints a fresh UUID that desyncs other inbounds.
+		if client.ID == "" {
+			client.ID = existing.UUID
+		}
+		if client.Password == "" {
+			client.Password = existing.Password
+		}
+		if client.Auth == "" {
+			client.Auth = existing.Auth
+		}
+		if client.Secret == "" {
+			client.Secret = existing.Secret
+		}
 	}
 	}
 
 
 	if client.SubID != "" {
 	if client.SubID != "" {
@@ -345,6 +359,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 	if updated.Auth == "" {
 	if updated.Auth == "" {
 		updated.Auth = existing.Auth
 		updated.Auth = existing.Auth
 	}
 	}
+	if updated.Secret == "" {
+		updated.Secret = existing.Secret
+	}
 
 
 	if updated.Email != existing.Email {
 	if updated.Email != existing.Email {
 		var collisionCount int64
 		var collisionCount int64
@@ -356,11 +373,6 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		if collisionCount > 0 {
 		if collisionCount > 0 {
 			return false, common.NewError("Duplicate email:", updated.Email)
 			return false, common.NewError("Duplicate email:", updated.Email)
 		}
 		}
-		if err := database.GetDB().Model(&model.ClientRecord{}).
-			Where("id = ?", id).
-			Update("email", updated.Email).Error; err != nil {
-			return false, err
-		}
 	}
 	}
 
 
 	if updated.SubID != "" {
 	if updated.SubID != "" {
@@ -411,6 +423,16 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		}
 		}
 	}
 	}
 
 
+	// UpdateInboundClient renames the record atomically with each inbound's
+	// settings JSON; this direct write only covers records with no inbound left.
+	if updated.Email != existing.Email {
+		if err := database.GetDB().Model(&model.ClientRecord{}).
+			Where("id = ? AND email = ?", id, existing.Email).
+			Update("email", updated.Email).Error; err != nil {
+			return needRestart, err
+		}
+	}
+
 	reverseStr := ""
 	reverseStr := ""
 	if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
 	if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
 		if b, mErr := json.Marshal(updated.Reverse); mErr == nil {
 		if b, mErr := json.Marshal(updated.Reverse); mErr == nil {
@@ -471,12 +493,14 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 	}
 	}
 
 
 	needRestart := false
 	needRestart := false
+	var delErrs []error
 	for _, ibId := range inboundIds {
 	for _, ibId := range inboundIds {
 		if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
 		if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
 			if errors.Is(getErr, gorm.ErrRecordNotFound) {
 			if errors.Is(getErr, gorm.ErrRecordNotFound) {
 				continue
 				continue
 			}
 			}
-			return needRestart, getErr
+			delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, getErr))
+			continue
 		}
 		}
 
 
 		// Always delete by email — the client's stable identity. This removes
 		// Always delete by email — the client's stable identity. This removes
@@ -493,12 +517,18 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 			if errors.Is(delErr, ErrClientNotInInbound) {
 			if errors.Is(delErr, ErrClientNotInInbound) {
 				continue
 				continue
 			}
 			}
-			return needRestart, delErr
+			delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, delErr))
+			continue
 		}
 		}
 		if nr {
 		if nr {
 			needRestart = true
 			needRestart = true
 		}
 		}
 	}
 	}
+	// A failed inbound still holds the client in its settings JSON: keep the
+	// record so the next delete retries exactly the leftovers, and report it.
+	if len(delErrs) > 0 {
+		return needRestart, errors.Join(delErrs...)
+	}
 
 
 	db := database.GetDB()
 	db := database.GetDB()
 	if err := db.Transaction(func(tx *gorm.DB) error {
 	if err := db.Transaction(func(tx *gorm.DB) error {
@@ -646,18 +676,23 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
 		return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email))
 		return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email))
 	}
 	}
 	needRestart := false
 	needRestart := false
+	var delErrs []error
 	for _, ibId := range inboundIds {
 	for _, ibId := range inboundIds {
 		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false, true)
 		nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false, true)
 		if delErr != nil {
 		if delErr != nil {
 			if errors.Is(delErr, ErrClientNotInInbound) {
 			if errors.Is(delErr, ErrClientNotInInbound) {
 				continue
 				continue
 			}
 			}
-			return needRestart, delErr
+			delErrs = append(delErrs, fmt.Errorf("inbound %d: %w", ibId, delErr))
+			continue
 		}
 		}
 		if nr {
 		if nr {
 			needRestart = true
 			needRestart = true
 		}
 		}
 	}
 	}
+	if len(delErrs) > 0 {
+		return needRestart, errors.Join(delErrs...)
+	}
 	if !keepTraffic {
 	if !keepTraffic {
 		db := database.GetDB()
 		db := database.GetDB()
 		if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
 		if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {

+ 64 - 0
internal/web/service/client_delete_continue_test.go

@@ -0,0 +1,64 @@
+package service
+
+import (
+	"strconv"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestDeleteContinuesPastFailedInbound(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{{Email: "spread@x", ID: "cccccccc-0000-0000-0000-000000000001", SubID: "sub-spread", Enable: true}}
+	ib1 := mkInbound(t, 23001, model.VLESS, clientsSettings(t, source))
+	ib2 := mkInbound(t, 23002, model.VLESS, clientsSettings(t, source))
+	ib3 := mkInbound(t, 23003, model.VLESS, clientsSettings(t, source))
+	for _, ib := range []*model.Inbound{ib1, ib2, ib3} {
+		if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+			t.Fatalf("seed linkage for %d: %v", ib.Id, err)
+		}
+	}
+	rec := lookupClientRecord(t, "spread@x")
+
+	missingNode := 9999
+	if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", ib2.Id).
+		Update("node_id", missingNode).Error; err != nil {
+		t.Fatalf("point inbound 2 at a missing node: %v", err)
+	}
+
+	_, err := svc.Delete(inboundSvc, rec.Id, false)
+	if err == nil {
+		t.Fatalf("Delete with a failing inbound succeeded, want error")
+	}
+	if !strings.Contains(err.Error(), "inbound "+strconv.Itoa(ib2.Id)) {
+		t.Fatalf("Delete error = %q, want it to name inbound %d", err, ib2.Id)
+	}
+
+	for _, ib := range []*model.Inbound{ib1, ib3} {
+		if settingsHoldUUID(t, inboundSvc, ib.Id, "spread@x") {
+			t.Fatalf("inbound %d still holds the client after Delete", ib.Id)
+		}
+	}
+	if _, err := svc.GetByID(rec.Id); err != nil {
+		t.Fatalf("record removed despite a failed inbound: %v", err)
+	}
+
+	if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", ib2.Id).
+		Update("node_id", nil).Error; err != nil {
+		t.Fatalf("repair inbound 2: %v", err)
+	}
+	if _, err := svc.Delete(inboundSvc, rec.Id, false); err != nil {
+		t.Fatalf("retry Delete: %v", err)
+	}
+	if _, err := svc.GetByID(rec.Id); err == nil {
+		t.Fatalf("record still present after successful retry")
+	}
+	if settingsHoldUUID(t, inboundSvc, ib2.Id, "spread@x") {
+		t.Fatalf("inbound 2 still holds the client after retry")
+	}
+}

+ 13 - 0
internal/web/service/client_inbound_apply.go

@@ -805,6 +805,19 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 		if e := tx.Save(oldInbound).Error; e != nil {
 		if e := tx.Save(oldInbound).Error; e != nil {
 			return e
 			return e
 		}
 		}
+		// Rename the client record in the same transaction as the settings JSON
+		// so no concurrent SyncInbound can see one renamed without the other.
+		if len(oldEmail) > 0 && !strings.EqualFold(oldEmail, clients[0].Email) {
+			var renameTaken int64
+			if e := tx.Model(&model.ClientRecord{}).Where("email = ?", clients[0].Email).Count(&renameTaken).Error; e != nil {
+				return e
+			}
+			if renameTaken == 0 {
+				if e := tx.Model(&model.ClientRecord{}).Where("email = ?", oldEmail).Update("email", clients[0].Email).Error; e != nil {
+					return e
+				}
+			}
+		}
 		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
 		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
 		if gcErr != nil {
 		if gcErr != nil {
 			return gcErr
 			return gcErr

+ 88 - 0
internal/web/service/client_update_rename_test.go

@@ -0,0 +1,88 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func countClientRecords(t *testing.T) int64 {
+	t.Helper()
+	var n int64
+	if err := database.GetDB().Model(&model.ClientRecord{}).Count(&n).Error; err != nil {
+		t.Fatalf("count client records: %v", err)
+	}
+	return n
+}
+
+func TestUpdateInboundClientRenameDoesNotDuplicateRecord(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{{Email: "old@x", ID: "aaaaaaaa-0000-0000-0000-000000000001", SubID: "sub-old", Enable: true}}
+	ib := mkInbound(t, 22001, model.VLESS, clientsSettings(t, source))
+	if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	origId := lookupClientRecord(t, "old@x").Id
+
+	renamed := source
+	renamed[0].Email = "new@x"
+	if _, err := svc.UpdateInboundClient(inboundSvc, &model.Inbound{
+		Id:       ib.Id,
+		Settings: clientsSettings(t, renamed),
+	}, "old@x"); err != nil {
+		t.Fatalf("UpdateInboundClient: %v", err)
+	}
+
+	if n := countClientRecords(t); n != 1 {
+		t.Fatalf("client records after rename = %d, want 1", n)
+	}
+	rec := lookupClientRecord(t, "new@x")
+	if rec.Id != origId {
+		t.Fatalf("record id after rename = %d, want %d", rec.Id, origId)
+	}
+}
+
+func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	source := []model.Client{
+		{Email: "keep@x", ID: "aaaaaaaa-0000-0000-0000-000000000003", SubID: "sub-keep", Enable: true},
+		{Email: "other@x", ID: "aaaaaaaa-0000-0000-0000-000000000004", SubID: "sub-other", Enable: true},
+	}
+	ib := mkInbound(t, 22003, model.VLESS, clientsSettings(t, source))
+	if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
+		t.Fatalf("seed linkage: %v", err)
+	}
+	origId := lookupClientRecord(t, "keep@x").Id
+	origSettings := mustInboundSettings(t, inboundSvc, ib.Id)
+
+	updated := source[0]
+	updated.Email = "kept@x"
+	updated.SubID = "sub-other"
+	if _, err := svc.Update(inboundSvc, origId, updated); err == nil {
+		t.Fatalf("Update with colliding subId succeeded, want error")
+	}
+
+	rec := lookupClientRecord(t, "keep@x")
+	if rec.Id != origId {
+		t.Fatalf("record id changed after rejected update")
+	}
+	if got := mustInboundSettings(t, inboundSvc, ib.Id); got != origSettings {
+		t.Fatalf("inbound settings changed after rejected update")
+	}
+}
+
+func mustInboundSettings(t *testing.T, inboundSvc *InboundService, id int) string {
+	t.Helper()
+	ib, err := inboundSvc.GetInbound(id)
+	if err != nil {
+		t.Fatalf("GetInbound %d: %v", id, err)
+	}
+	return ib.Settings
+}

+ 15 - 0
internal/web/service/host.go

@@ -154,6 +154,21 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
 	return rows
 	return rows
 }
 }
 
 
+// adoptedHostRows projects a node's host groups onto a freshly adopted central
+// inbound so TLS/SNI/fingerprint overrides survive the node-to-master import.
+func adoptedHostRows(groups []*entity.HostGroup, nodeInboundId, centralInboundId int) []*model.Host {
+	var rows []*model.Host
+	for _, g := range groups {
+		if g == nil || !slices.Contains(g.InboundIds, nodeInboundId) {
+			continue
+		}
+		scoped := *g
+		scoped.InboundIds = []int{centralInboundId}
+		rows = append(rows, buildHostRows(g.GroupId, &scoped)...)
+	}
+	return rows
+}
+
 func validateInboundsExist(tx *gorm.DB, inboundIds []int) error {
 func validateInboundsExist(tx *gorm.DB, inboundIds []int) error {
 	for _, inboundId := range inboundIds {
 	for _, inboundId := range inboundIds {
 		var count int64
 		var count int64

+ 29 - 1
internal/web/service/inbound.go

@@ -503,7 +503,9 @@ func (s *InboundService) getAllEmailSubIDs() (map[string]string, error) {
 // Only vmess, vless, trojan, shadowsocks, hysteria, wireguard, and tunnel
 // Only vmess, vless, trojan, shadowsocks, hysteria, wireguard, and tunnel
 // protocols use streamSettings (wireguard for finalmask UDP masks and sockopt on
 // protocols use streamSettings (wireguard for finalmask UDP masks and sockopt on
 // its listener; tunnel for sockopt, notably sockopt.tproxy for its TProxy/redirect
 // its listener; tunnel for sockopt, notably sockopt.tproxy for its TProxy/redirect
-// mode).
+// mode). Streams keyed on "method" — xray-core v26.7.11's preferred alias for
+// "network" — are canonicalized to "network", which every panel reader (link
+// generation, port-conflict detection, flow eligibility) keys on.
 func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 	protocolsWithStream := map[model.Protocol]bool{
 	protocolsWithStream := map[model.Protocol]bool{
 		model.VMESS:       true,
 		model.VMESS:       true,
@@ -517,7 +519,33 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
 
 
 	if !protocolsWithStream[inbound.Protocol] {
 	if !protocolsWithStream[inbound.Protocol] {
 		inbound.StreamSettings = ""
 		inbound.StreamSettings = ""
+		return
+	}
+	inbound.StreamSettings = canonicalizeStreamNetworkKey(inbound.StreamSettings)
+}
+
+// canonicalizeStreamNetworkKey rewrites a streamSettings JSON that names its
+// transport under "method" to the panel-canonical "network" key. When both
+// keys are present, "method" wins — matching xray-core's own precedence.
+func canonicalizeStreamNetworkKey(streamSettings string) string {
+	if streamSettings == "" {
+		return streamSettings
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+		return streamSettings
+	}
+	method, ok := stream["method"].(string)
+	if !ok || method == "" {
+		return streamSettings
 	}
 	}
+	stream["network"] = method
+	delete(stream, "method")
+	out, err := json.MarshalIndent(stream, "", "  ")
+	if err != nil {
+		return streamSettings
+	}
+	return string(out)
 }
 }
 
 
 // finalMaskRealityTcpMasks returns the stream's finalmask.tcp masks when the
 // finalMaskRealityTcpMasks returns the stream's finalmask.tcp masks when the

+ 45 - 0
internal/web/service/inbound_node.go

@@ -129,6 +129,11 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 		}
 		}
 	}
 	}
+	// Before the first clean sync adopts the node's inbounds, "absent locally"
+	// means "not imported yet" — sweeping now would wipe the node at onboarding.
+	if n.InboundsAdoptedAt == 0 {
+		return errors.Join(errs...)
+	}
 	// In "selected" sync mode the panel only manages the selected tags: the
 	// In "selected" sync mode the panel only manages the selected tags: the
 	// rest were never imported, so their absence from the local DB must not
 	// rest were never imported, so their absence from the local DB must not
 	// delete them from the node. Only a selected tag missing locally (the
 	// delete them from the node. Only a selected tag missing locally (the
@@ -219,6 +224,41 @@ func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
 	).Error
 	).Error
 }
 }
 
 
+// SnapshotHasUnadoptedInbounds reports whether the snapshot carries a tag with
+// no central row yet, i.e. the next merge would adopt a new inbound.
+func (s *InboundService) SnapshotHasUnadoptedInbounds(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
+	if snap == nil || len(snap.Inbounds) == 0 {
+		return false, nil
+	}
+	var tags []string
+	if err := database.GetDB().Model(model.Inbound{}).
+		Where("node_id = ?", nodeID).
+		Pluck("tag", &tags).Error; err != nil {
+		return false, err
+	}
+	prefix := nodeTagPrefix(&nodeID)
+	known := make(map[string]struct{}, len(tags)*2)
+	for _, tag := range tags {
+		known[tag] = struct{}{}
+		if prefix != "" {
+			if stripped, found := strings.CutPrefix(tag, prefix); found {
+				known[stripped] = struct{}{}
+			} else {
+				known[prefix+tag] = struct{}{}
+			}
+		}
+	}
+	for _, ib := range snap.Inbounds {
+		if ib == nil {
+			continue
+		}
+		if _, ok := known[ib.Tag]; !ok {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
 func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
 	var structuralChange bool
 	var structuralChange bool
 	err := submitTrafficWrite(func() error {
 	err := submitTrafficWrite(func() error {
@@ -532,6 +572,11 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			if newIb.Tag != snapIb.Tag {
 			if newIb.Tag != snapIb.Tag {
 				tagToCentral[newIb.Tag] = &newIb
 				tagToCentral[newIb.Tag] = &newIb
 			}
 			}
+			if rows := adoptedHostRows(snap.HostGroups, snapIb.Id, newIb.Id); len(rows) > 0 {
+				if err := tx.Create(&rows).Error; err != nil {
+					logger.Warningf("setRemoteTraffic: adopt host rows for tag %q failed: %v", newIb.Tag, err)
+				}
+			}
 			newInboundIDs[newIb.Id] = struct{}{}
 			newInboundIDs[newIb.Id] = struct{}{}
 			structuralChange = true
 			structuralChange = true
 			continue
 			continue

+ 26 - 0
internal/web/service/inbound_node_reconcile_test.go

@@ -87,6 +87,7 @@ func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tag
 		Status:              "online",
 		Status:              "online",
 		InboundSyncMode:     mode,
 		InboundSyncMode:     mode,
 		InboundTags:         tags,
 		InboundTags:         tags,
+		InboundsAdoptedAt:   1,
 	}
 	}
 	if err := database.GetDB().Create(n).Error; err != nil {
 	if err := database.GetDB().Create(n).Error; err != nil {
 		t.Fatalf("create node: %v", err)
 		t.Fatalf("create node: %v", err)
@@ -143,6 +144,31 @@ func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// A node whose pre-existing inbounds were never adopted into the central DB
+// has zero local rows for legitimate reasons: reconcile before that first
+// adoption must not sweep — it would delete every real inbound on the node
+// right after onboarding (add node, save it again, watch it get wiped).
+func TestReconcileNode_SkipsSweepBeforeFirstAdoption(t *testing.T) {
+	setupConflictDB(t)
+
+	ts, deletedIDs := fakeNodePanel(t, map[string]int{
+		"real-a": 1,
+		"real-b": 2,
+		"real-c": 3,
+	})
+	node := reconcileTestNode(t, ts, "fresh-node", "all", nil)
+	node.InboundsAdoptedAt = 0
+
+	svc := InboundService{}
+	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
+		t.Fatalf("ReconcileNode: %v", err)
+	}
+
+	if got := deletedIDs(); len(got) != 0 {
+		t.Fatalf("deleted remote ids = %v, want none before first adoption", got)
+	}
+}
+
 // One inbound the node rejects (e.g. a legacy protocol failing the node's
 // One inbound the node rejects (e.g. a legacy protocol failing the node's
 // request validation, #5685) must not abort the reconcile: the healthy inbound
 // request validation, #5685) must not abort the reconcile: the healthy inbound
 // is still pushed, the delete sweep still runs, and the returned error names
 // is still pushed, the delete sweep still runs, and the returned error names

+ 9 - 0
internal/web/service/node.go

@@ -771,6 +771,15 @@ func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
 		Update("config_dirty", false).Error
 		Update("config_dirty", false).Error
 }
 }
 
 
+func (s *NodeService) MarkNodeInboundsAdopted(id int) error {
+	if id <= 0 {
+		return nil
+	}
+	return database.GetDB().Model(model.Node{}).
+		Where("id = ? AND inbounds_adopted_at = 0", id).
+		Update("inbounds_adopted_at", time.Now().Unix()).Error
+}
+
 func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
 func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
 	if id <= 0 {
 	if id <= 0 {
 		return false, "", false, 0, errors.New("invalid node id")
 		return false, "", false, 0, errors.New("invalid node id")

+ 77 - 0
internal/web/service/node_host_adopt_test.go

@@ -0,0 +1,77 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+)
+
+func TestSetRemoteTraffic_AdoptsNodeHostRows(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	const nodeID = 6
+	if err := db.Create(&model.Node{
+		Id:       nodeID,
+		Name:     "host-node",
+		Address:  "10.0.0.6",
+		Port:     2053,
+		ApiToken: "t",
+		Guid:     "host-node-guid",
+	}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{{
+			Id:       77,
+			Tag:      "host-adopt-443",
+			Enable:   true,
+			Port:     443,
+			Protocol: model.VLESS,
+			Settings: `{"clients":[]}`,
+		}},
+		HostGroups: []*entity.HostGroup{{
+			GroupId:     "g-node",
+			InboundIds:  []int{77, 99},
+			Hosts:       []string{"cdn.example.com:8443"},
+			Remark:      "cdn",
+			Security:    "tls",
+			Sni:         "sni.example.com",
+			Fingerprint: "firefox",
+		}},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	var central model.Inbound
+	if err := db.Where("tag = ?", "host-adopt-443").First(&central).Error; err != nil {
+		t.Fatalf("load adopted inbound: %v", err)
+	}
+	var hosts []model.Host
+	if err := db.Where("inbound_id = ?", central.Id).Find(&hosts).Error; err != nil {
+		t.Fatalf("load adopted hosts: %v", err)
+	}
+	if len(hosts) != 1 {
+		t.Fatalf("adopted host rows = %d, want 1", len(hosts))
+	}
+	h := hosts[0]
+	if h.GroupId != "g-node" || h.Address != "cdn.example.com" || h.Port != 8443 ||
+		h.Security != "tls" || h.Sni != "sni.example.com" || h.Fingerprint != "firefox" || h.Remark != "cdn" {
+		t.Fatalf("adopted host mismatch: %+v", h)
+	}
+
+	var total int64
+	if err := db.Model(&model.Host{}).Count(&total).Error; err != nil {
+		t.Fatalf("count hosts: %v", err)
+	}
+	if total != 1 {
+		t.Fatalf("total host rows = %d, want 1 (group member for un-adopted node inbound 99 must not materialize)", total)
+	}
+}

+ 38 - 7
internal/web/service/outbound_subscription.go

@@ -17,8 +17,34 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 )
 
 
+// filterOutboundsRejectedByCore drops outbounds the vendored xray-core config
+// loader refuses to build — since v26.7.11 that includes unencrypted
+// vless/trojan outbounds to public addresses — because one such outbound in
+// the merged config would keep the whole core from starting.
+func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
+	kept := make([]any, 0, len(outbounds))
+	var dropped []string
+	for _, ob := range outbounds {
+		raw, err := json.Marshal(ob)
+		if err == nil {
+			if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil {
+				tag := ""
+				if m, ok := ob.(map[string]any); ok {
+					tag, _ = m["tag"].(string)
+				}
+				logger.Warningf("%s: dropping outbound %q rejected by xray-core: %v", label, tag, buildErr)
+				dropped = append(dropped, fmt.Sprintf("%s: %v", tag, buildErr))
+				continue
+			}
+		}
+		kept = append(kept, ob)
+	}
+	return kept, dropped
+}
+
 // maxOutboundSubscriptionBytes caps a single outbound subscription response.
 // maxOutboundSubscriptionBytes caps a single outbound subscription response.
 // It is larger than the 2 MiB user-facing subscription cap because an outbound
 // It is larger than the 2 MiB user-facing subscription cap because an outbound
 // subscription may aggregate many upstream outbounds into one document.
 // subscription may aggregate many upstream outbounds into one document.
@@ -347,24 +373,28 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 	}
 	}
 	identJSON, _ := json.Marshal(newIdent)
 	identJSON, _ := json.Marshal(newIdent)
 
 
+	asAny := make([]any, len(parsed))
+	for i := range parsed {
+		asAny[i] = map[string]any(parsed[i])
+	}
+	kept, droppedByCore := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), asAny)
+
 	// Persist the outbounds (as compact JSON array)
 	// Persist the outbounds (as compact JSON array)
-	obsJSON, _ := json.Marshal(parsed)
+	obsJSON, _ := json.Marshal(kept)
 
 
 	sub.LastFetchedOutbounds = string(obsJSON)
 	sub.LastFetchedOutbounds = string(obsJSON)
 	sub.LinkIdentities = string(identJSON)
 	sub.LinkIdentities = string(identJSON)
 	sub.LastUpdated = time.Now().Unix()
 	sub.LastUpdated = time.Now().Unix()
 	sub.LastError = ""
 	sub.LastError = ""
+	if len(droppedByCore) > 0 {
+		sub.LastError = fmt.Sprintf("dropped %d outbound(s) the xray core rejects: %s", len(droppedByCore), droppedByCore[0])
+	}
 
 
 	if err := database.GetDB().Save(sub).Error; err != nil {
 	if err := database.GetDB().Save(sub).Error; err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	// Return as []any for the config merger
-	result := make([]any, len(parsed))
-	for i := range parsed {
-		result[i] = parsed[i]
-	}
-	return result, nil
+	return kept, nil
 }
 }
 
 
 func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
 func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
@@ -456,6 +486,7 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app
 			logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
 			logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
 			continue
 			continue
 		}
 		}
+		arr, _ = filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), arr)
 		if sub.Prepend {
 		if sub.Prepend {
 			prepend = append(prepend, arr...)
 			prepend = append(prepend, arr...)
 		} else {
 		} else {

+ 57 - 0
internal/web/service/stream_network_alias_test.go

@@ -0,0 +1,57 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+// TestCanonicalizeStreamNetworkKey covers xray-core v26.7.11's "method" alias
+// for streamSettings "network": a config keyed on "method" (from an imported
+// or API-authored inbound) must be folded back to the panel-canonical
+// "network" key that every downstream reader — link generation, port-conflict
+// detection, flow eligibility — depends on.
+func TestCanonicalizeStreamNetworkKey(t *testing.T) {
+	tests := []struct {
+		name        string
+		in          string
+		wantNetwork string
+		wantMethod  bool
+	}{
+		{
+			name:        "method alias becomes network",
+			in:          `{"method": "ws", "security": "tls"}`,
+			wantNetwork: "ws",
+		},
+		{
+			name:        "method wins when both present",
+			in:          `{"method": "grpc", "network": "tcp"}`,
+			wantNetwork: "grpc",
+		},
+		{
+			name:        "plain network untouched",
+			in:          `{"network": "tcp"}`,
+			wantNetwork: "tcp",
+		},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			got := canonicalizeStreamNetworkKey(tc.in)
+			var stream map[string]any
+			if err := json.Unmarshal([]byte(got), &stream); err != nil {
+				t.Fatalf("result is not valid JSON: %v", err)
+			}
+			if stream["network"] != tc.wantNetwork {
+				t.Fatalf("network = %v, want %q", stream["network"], tc.wantNetwork)
+			}
+			if _, hasMethod := stream["method"]; hasMethod {
+				t.Fatalf("method key must be removed, got %s", got)
+			}
+		})
+	}
+}
+
+func TestCanonicalizeStreamNetworkKey_EmptyPassthrough(t *testing.T) {
+	if got := canonicalizeStreamNetworkKey(""); got != "" {
+		t.Fatalf("empty stream must round-trip, got %q", got)
+	}
+}

+ 15 - 0
internal/web/service/xray_setting.go

@@ -41,6 +41,21 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
 	if err != nil {
 	if err != nil {
 		return common.NewError("xray template config invalid:", err)
 		return common.NewError("xray template config invalid:", err)
 	}
 	}
+	if len(xrayConfig.OutboundConfigs) > 0 {
+		var outbounds []json.RawMessage
+		if err := json.Unmarshal(xrayConfig.OutboundConfigs, &outbounds); err != nil {
+			return common.NewError("xray template config invalid: outbounds is not an array:", err)
+		}
+		for _, outbound := range outbounds {
+			if err := xray.ValidateOutboundConfig(outbound); err != nil {
+				tagged := struct {
+					Tag string `json:"tag"`
+				}{}
+				_ = json.Unmarshal(outbound, &tagged)
+				return common.NewError("xray core rejects outbound \""+tagged.Tag+"\":", err)
+			}
+		}
+	}
 	return nil
 	return nil
 }
 }
 
 

+ 17 - 2
internal/xray/api.go

@@ -171,6 +171,19 @@ func (x *XrayAPI) DelInbound(tag string) error {
 	return err
 	return err
 }
 }
 
 
+// ValidateOutboundConfig builds an outbound JSON object through the vendored
+// xray-core config loader, surfacing the exact error the core would raise at
+// startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds
+// whose server address is a public IP or domain.
+func ValidateOutboundConfig(outbound []byte) error {
+	detour := new(conf.OutboundDetourConfig)
+	if err := json.Unmarshal(outbound, detour); err != nil {
+		return err
+	}
+	_, err := detour.Build()
+	return err
+}
+
 // AddOutbound adds a new outbound configuration to the Xray core via gRPC.
 // AddOutbound adds a new outbound configuration to the Xray core via gRPC.
 func (x *XrayAPI) AddOutbound(outbound []byte) error {
 func (x *XrayAPI) AddOutbound(outbound []byte) error {
 	if x.HandlerServiceClient == nil {
 	if x.HandlerServiceClient == nil {
@@ -518,6 +531,8 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
 
 
 		var ssCipherType shadowsocks.CipherType
 		var ssCipherType shadowsocks.CipherType
 		switch cipher {
 		switch cipher {
+		case "aes-128-gcm":
+			ssCipherType = shadowsocks.CipherType_AES_128_GCM
 		case "aes-256-gcm":
 		case "aes-256-gcm":
 			ssCipherType = shadowsocks.CipherType_AES_256_GCM
 			ssCipherType = shadowsocks.CipherType_AES_256_GCM
 		case "chacha20-poly1305", "chacha20-ietf-poly1305":
 		case "chacha20-poly1305", "chacha20-ietf-poly1305":
@@ -525,10 +540,10 @@ func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMe
 		case "xchacha20-poly1305", "xchacha20-ietf-poly1305":
 		case "xchacha20-poly1305", "xchacha20-ietf-poly1305":
 			ssCipherType = shadowsocks.CipherType_XCHACHA20_POLY1305
 			ssCipherType = shadowsocks.CipherType_XCHACHA20_POLY1305
 		default:
 		default:
-			ssCipherType = shadowsocks.CipherType_NONE
+			ssCipherType = shadowsocks.CipherType_UNKNOWN
 		}
 		}
 
 
-		if ssCipherType != shadowsocks.CipherType_NONE {
+		if ssCipherType != shadowsocks.CipherType_UNKNOWN {
 			return serial.ToTypedMessage(&shadowsocks.Account{
 			return serial.ToTypedMessage(&shadowsocks.Account{
 				Password:   password,
 				Password:   password,
 				CipherType: ssCipherType,
 				CipherType: ssCipherType,

+ 4 - 0
internal/xray/config.go

@@ -24,6 +24,7 @@ type Config struct {
 	BurstObservatory json_util.RawMessage `json:"burstObservatory,omitempty"`
 	BurstObservatory json_util.RawMessage `json:"burstObservatory,omitempty"`
 	Metrics          json_util.RawMessage `json:"metrics"`
 	Metrics          json_util.RawMessage `json:"metrics"`
 	Geodata          json_util.RawMessage `json:"geodata,omitempty"`
 	Geodata          json_util.RawMessage `json:"geodata,omitempty"`
+	Env              json_util.RawMessage `json:"env,omitempty"`
 }
 }
 
 
 // Equals compares two Config instances for deep equality.
 // Equals compares two Config instances for deep equality.
@@ -78,5 +79,8 @@ func (c *Config) Equals(other *Config) bool {
 	if !bytes.Equal(c.Geodata, other.Geodata) {
 	if !bytes.Equal(c.Geodata, other.Geodata) {
 		return false
 		return false
 	}
 	}
+	if !bytes.Equal(c.Env, other.Env) {
+		return false
+	}
 	return true
 	return true
 }
 }

+ 1 - 0
internal/xray/hot_diff.go

@@ -70,6 +70,7 @@ func ComputeHotDiff(oldCfg, newCfg *Config) (*HotDiff, bool) {
 		{"burstObservatory", oldCfg.BurstObservatory, newCfg.BurstObservatory},
 		{"burstObservatory", oldCfg.BurstObservatory, newCfg.BurstObservatory},
 		{"metrics", oldCfg.Metrics, newCfg.Metrics},
 		{"metrics", oldCfg.Metrics, newCfg.Metrics},
 		{"geodata", oldCfg.Geodata, newCfg.Geodata},
 		{"geodata", oldCfg.Geodata, newCfg.Geodata},
+		{"env", oldCfg.Env, newCfg.Env},
 	}
 	}
 	for _, section := range static {
 	for _, section := range static {
 		if !rawEqualNormalized(section.old, section.new) {
 		if !rawEqualNormalized(section.old, section.new) {

+ 6 - 0
internal/xray/hot_diff_test.go

@@ -143,6 +143,12 @@ func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) {
 	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
 	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
 		t.Fatal("observatory change must force a restart")
 		t.Fatal("observatory change must force a restart")
 	}
 	}
+
+	newCfg = makeHotConfig()
+	newCfg.Env = json_util.RawMessage(`{"XRAY_DNS_PATH":"/tmp/dns"}`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("env change must force a restart: env vars are read only at process start")
+	}
 }
 }
 
 
 func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
 func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {

+ 42 - 0
internal/xray/outbound_validation_test.go

@@ -0,0 +1,42 @@
+package xray
+
+import (
+	"strings"
+	"testing"
+)
+
+// TestValidateOutboundConfig_RejectsUnencryptedPublicVless covers xray-core
+// v26.7.11's refusal to build an unencrypted vless outbound to a public
+// address — the check now runs in-process, so the panel can surface it before
+// a config reaches the core and bricks startup. A private-address outbound and
+// a TLS outbound stay valid.
+func TestValidateOutboundConfig_RejectsUnencryptedPublicVless(t *testing.T) {
+	publicPlaintext := `{
+		"protocol": "vless",
+		"settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"},
+		"streamSettings": {"network": "tcp", "security": "none"}
+	}`
+	if err := ValidateOutboundConfig([]byte(publicPlaintext)); err == nil {
+		t.Fatal("expected a public unencrypted vless outbound to be rejected")
+	} else if !strings.Contains(err.Error(), "prohibited") {
+		t.Fatalf("expected a prohibition error, got: %v", err)
+	}
+
+	privatePlaintext := `{
+		"protocol": "vless",
+		"settings": {"address": "10.0.0.1", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"},
+		"streamSettings": {"network": "tcp", "security": "none"}
+	}`
+	if err := ValidateOutboundConfig([]byte(privatePlaintext)); err != nil {
+		t.Fatalf("a private-address plaintext vless outbound must stay valid, got: %v", err)
+	}
+
+	publicTLS := `{
+		"protocol": "vless",
+		"settings": {"address": "1.2.3.4", "port": 443, "id": "b831381d-6324-4d53-ad4f-8cda48b30811", "encryption": "none"},
+		"streamSettings": {"network": "tcp", "security": "tls", "tlsSettings": {"serverName": "example.com"}}
+	}`
+	if err := ValidateOutboundConfig([]byte(publicTLS)); err != nil {
+		t.Fatalf("a TLS-secured public vless outbound must stay valid, got: %v", err)
+	}
+}

+ 16 - 0
update.sh

@@ -667,6 +667,22 @@ prompt_and_setup_ssl() {
             # User chose Let's Encrypt IP certificate option
             # User chose Let's Encrypt IP certificate option
             echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
             echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
 
 
+            # Confirm the auto-detected IP before issuing for it: with asymmetric
+            # routing / multi-WAN the echo services can return a transit address.
+            local ip_confirm=""
+            read -rp "Is ${server_ip} the correct incoming public IPv4 address for this server? [Default y]: " ip_confirm
+            if [[ -n "$ip_confirm" && "$ip_confirm" != "y" && "$ip_confirm" != "Y" ]]; then
+                server_ip=""
+                while [[ -z "$server_ip" ]]; do
+                    read -rp "Please enter your server's public IPv4 address: " server_ip
+                    server_ip="${server_ip// /}"
+                    if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+                        echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
+                        server_ip=""
+                    fi
+                done
+            fi
+
             # Ask for optional IPv6
             # Ask for optional IPv6
             local ipv6_addr=""
             local ipv6_addr=""
             read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
             read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr

+ 16 - 10
x-ui.sh

@@ -1548,19 +1548,25 @@ ssl_cert_issue_for_ip() {
         fi
         fi
     done
     done
 
 
-    if [[ -z "$server_ip" ]]; then
+    if [[ -n "$server_ip" ]]; then
+        LOGI "Server IP detected: ${server_ip}"
+        if ! confirm "Is ${server_ip} the correct incoming public IPv4 address for this server?" "y"; then
+            server_ip=""
+        fi
+    else
         LOGI "Could not auto-detect server IP from any provider."
         LOGI "Could not auto-detect server IP from any provider."
-        while [[ -z "$server_ip" ]]; do
-            read -rp "Please enter your server's public IPv4 address: " server_ip
-            server_ip="${server_ip// /}"
-            if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
-                LOGE "Invalid IPv4 address. Please try again."
-                server_ip=""
-            fi
-        done
     fi
     fi
 
 
-    LOGI "Server IP detected: ${server_ip}"
+    while [[ -z "$server_ip" ]]; do
+        read -rp "Please enter your server's public IPv4 address: " server_ip
+        server_ip="${server_ip// /}"
+        if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+            LOGE "Invalid IPv4 address. Please try again."
+            server_ip=""
+        fi
+    done
+
+    LOGI "Issuing certificate for server IP: ${server_ip}"
 
 
     # Ask for optional IPv6
     # Ask for optional IPv6
     local ipv6_addr=""
     local ipv6_addr=""