13 Commits 7c2598fae9 ... 53f6ed394f

Author SHA1 Message Date
  Abdalrahman 53f6ed394f Add Enable/Disable Toggle for Xray Routing Rules (#5296) 1 month ago
  Volov Vyacheslav 66a9a788fc fix(reality): load `dest` as `target` alias so existing inbounds aren't wiped (#5295) 1 month ago
  Rouzbeh† dab0add191 feat(finalmask): support Salamander packetSize (Gecko) and Realm tlsConfig for Hysteria2 (#5278) 1 month ago
  Nikan Zeyaei 7c737820d1 fix(links): bracket ipv6 hosts in share links and qr codes (#5310) 1 month ago
  MHSanaei 335470607f fix(ui): match node connection-outbound picker to panel-outbound selector 1 month ago
  Nikan Zeyaei 05ad7f417c feat(node): per node outbound routing (#5275) 1 month ago
  n0ctal 2188830612 perf(db): index group_name and client_traffics hot columns (#5268) 1 month ago
  n0ctal d14f341b21 refactor(web): centralize background job cadences (#5269) 1 month ago
  Nikan Zeyaei f4bbaf40f0 feat(ui): show per-inbound live speed (#5261) 1 month ago
  MHSanaei 1c75034957 ci(smoke): retry transient GitHub download failures 1 month ago
  Pavel 7f34c306d7 feat(docker): support XUI_PORT runtime override (#5240) 1 month ago
  MHSanaei a133282fc3 ci(smoke): set least-privilege GITHUB_TOKEN permissions 1 month ago
  MHSanaei dcb923b4a1 feat(sub): per-client external links and remote subscriptions 1 month ago
93 changed files with 2921 additions and 203 deletions
  1. 1 0
      .env.example
  2. 3 0
      .github/workflows/smoke.yml
  3. 8 0
      CONTRIBUTING.md
  4. 14 3
      deploy/test/smoke-firstboot.sh
  5. 3 1
      docker-compose.yml
  6. 74 1
      frontend/public/openapi.json
  7. 41 3
      frontend/src/api/queries/useOutboundTags.ts
  8. 6 10
      frontend/src/components/form/SelectAllClearButtons.tsx
  9. 1 0
      frontend/src/generated/examples.ts
  10. 4 0
      frontend/src/generated/schemas.ts
  11. 1 0
      frontend/src/generated/types.ts
  12. 1 0
      frontend/src/generated/zod.ts
  13. 17 1
      frontend/src/hooks/useClients.ts
  14. 178 17
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  15. 13 5
      frontend/src/lib/xray/inbound-link.ts
  16. 13 2
      frontend/src/pages/api-docs/endpoints.ts
  17. 108 3
      frontend/src/pages/clients/ClientFormModal.tsx
  18. 20 5
      frontend/src/pages/clients/ClientsPage.tsx
  19. 2 0
      frontend/src/pages/inbounds/InboundsPage.tsx
  20. 3 0
      frontend/src/pages/inbounds/list/InboundList.tsx
  21. 39 0
      frontend/src/pages/inbounds/list/InboundSpeedTag.tsx
  22. 14 1
      frontend/src/pages/inbounds/list/InboundStatsModal.tsx
  23. 6 0
      frontend/src/pages/inbounds/list/types.ts
  24. 18 2
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  25. 43 1
      frontend/src/pages/inbounds/useInbounds.ts
  26. 34 0
      frontend/src/pages/nodes/NodeFormModal.tsx
  27. 4 0
      frontend/src/pages/xray/routing/RoutingTab.css
  28. 10 0
      frontend/src/pages/xray/routing/RoutingTab.tsx
  29. 14 3
      frontend/src/pages/xray/routing/RuleCardList.tsx
  30. 17 1
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  31. 7 0
      frontend/src/pages/xray/routing/helpers.ts
  32. 1 0
      frontend/src/pages/xray/routing/types.ts
  33. 55 31
      frontend/src/pages/xray/routing/useRoutingColumns.tsx
  34. 12 0
      frontend/src/schemas/client.ts
  35. 2 0
      frontend/src/schemas/node.ts
  36. 41 18
      frontend/src/schemas/protocols/security/reality.ts
  37. 1 0
      frontend/src/schemas/routing.ts
  38. 1 0
      frontend/src/schemas/xray.ts
  39. 40 0
      frontend/src/test/__snapshots__/finalmask.test.ts.snap
  40. 20 0
      frontend/src/test/finalmask.test.ts
  41. 17 0
      frontend/src/test/golden/fixtures/finalmask/realm-tls.json
  42. 5 0
      frontend/src/test/golden/fixtures/finalmask/salamander-gecko.json
  43. 85 0
      frontend/src/test/inbound-link.test.ts
  44. 40 0
      frontend/src/test/outbound-link-parser.test.ts
  45. 36 0
      frontend/src/test/security.test.ts
  46. 54 0
      frontend/src/test/size-formatter.test.ts
  47. 6 1
      frontend/src/utils/index.ts
  48. 4 4
      install.sh
  49. 18 0
      internal/config/config.go
  50. 61 0
      internal/config/config_test.go
  51. 1 0
      internal/database/db.go
  52. 40 0
      internal/database/index_tags_test.go
  53. 1 0
      internal/database/migrate_data.go
  54. 27 1
      internal/database/model/model.go
  55. 238 0
      internal/sub/clash_external.go
  56. 20 1
      internal/sub/clash_service.go
  57. 160 0
      internal/sub/external_config.go
  58. 123 0
      internal/sub/external_config_test.go
  59. 133 0
      internal/sub/external_subscription.go
  60. 29 1
      internal/sub/json_service.go
  61. 35 9
      internal/sub/service.go
  62. 19 0
      internal/sub/service_test.go
  63. 34 0
      internal/web/cadence_test.go
  64. 26 1
      internal/web/controller/client.go
  65. 51 6
      internal/web/controller/node.go
  66. 24 4
      internal/web/runtime/manager.go
  67. 11 4
      internal/web/runtime/remote.go
  68. 1 1
      internal/web/runtime/remote_test.go
  69. 39 14
      internal/web/runtime/tls_client.go
  70. 4 4
      internal/web/runtime/tls_client_test.go
  71. 3 0
      internal/web/service/client_crud.go
  72. 103 0
      internal/web/service/client_external_link.go
  73. 2 2
      internal/web/service/inbound_node_reconcile_test.go
  74. 122 3
      internal/web/service/node.go
  75. 20 0
      internal/web/service/setting.go
  76. 145 0
      internal/web/service/xray.go
  77. 35 26
      internal/web/service/xray_setting.go
  78. 88 0
      internal/web/service/xray_strip_rules_test.go
  79. 10 0
      internal/web/translation/ar-EG.json
  80. 11 1
      internal/web/translation/en-US.json
  81. 10 0
      internal/web/translation/es-ES.json
  82. 11 1
      internal/web/translation/fa-IR.json
  83. 10 0
      internal/web/translation/id-ID.json
  84. 10 0
      internal/web/translation/ja-JP.json
  85. 10 0
      internal/web/translation/pt-BR.json
  86. 10 0
      internal/web/translation/ru-RU.json
  87. 10 0
      internal/web/translation/tr-TR.json
  88. 10 0
      internal/web/translation/uk-UA.json
  89. 10 0
      internal/web/translation/vi-VN.json
  90. 10 0
      internal/web/translation/zh-CN.json
  91. 10 0
      internal/web/translation/zh-TW.json
  92. 38 10
      internal/web/web.go
  93. 1 1
      internal/xray/client_traffic.go

+ 1 - 0
.env.example

@@ -3,3 +3,4 @@ XUI_DB_FOLDER=x-ui
 XUI_LOG_FOLDER=x-ui
 XUI_BIN_FOLDER=x-ui
 XUI_INIT_WEB_BASE_PATH=/
+# XUI_PORT=8080

+ 3 - 0
.github/workflows/smoke.yml

@@ -15,6 +15,9 @@ on:
       - "deploy/**"
       - ".github/workflows/smoke.yml"
 
+permissions:
+  contents: read
+
 jobs:
   noninteractive-install:
     strategy:

+ 8 - 0
CONTRIBUTING.md

@@ -73,6 +73,7 @@ XUI_DB_FOLDER=x-ui
 XUI_LOG_FOLDER=x-ui
 XUI_BIN_FOLDER=x-ui
 XUI_INIT_WEB_BASE_PATH=/
+# XUI_PORT=8080
 ```
 
 Drop the xray binary (`xray-windows-amd64.exe` on Windows, `xray-linux-amd64` on Linux, etc.) plus the matching `geoip.dat` and `geosite.dat` files into `x-ui/`. The easiest source is a [released Xray-core build](https://github.com/XTLS/Xray-core/releases). On Windows, `wintun.dll` is also required for testing TUN inbounds.
@@ -256,9 +257,16 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
 | `XUI_LOG_FOLDER` | platform default | Where `3xui.log` lives |
 | `XUI_BIN_FOLDER` | `bin` | Where the xray binary, geo files, and xray `config.json` live |
 | `XUI_INIT_WEB_BASE_PATH` | `/` | The initial URI path for the web panel |
+| `XUI_PORT` | persisted `webPort` | Runtime-only web panel listener port override (`1` through `65535`) |
 | `XUI_DB_TYPE` | `sqlite` | Set to `postgres` to use PostgreSQL via `XUI_DB_DSN` |
 | `XUI_DB_DSN` | — | PostgreSQL DSN when `XUI_DB_TYPE=postgres` |
 
+A valid `XUI_PORT` takes precedence over the database-backed `webPort` for the
+current process without changing the stored setting. Unset, empty, whitespace-only,
+malformed, or out-of-range values fall back to `webPort`; invalid configured values
+also produce a warning. With Docker bridge networking, the published container port
+must match the override, for example `XUI_PORT: "8080"` with `ports: ["8080:8080"]`.
+
 ## Issues
 
 - Bug reports and feature requests: [GitHub Issues](https://github.com/MHSanaei/3x-ui/issues)

+ 14 - 3
deploy/test/smoke-firstboot.sh

@@ -32,11 +32,22 @@ docker run --rm \
         REPO=MHSanaei/3x-ui
         ARCH=$(dpkg --print-architecture)   # amd64 | arm64
         echo "container arch: $ARCH"
-        VER=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
+        VER=$(curl --fail --location --silent --show-error \
+            --retry 5 --retry-all-errors --retry-delay 3 \
+            --connect-timeout 15 --max-time 60 \
+            "https://api.github.com/repos/${REPO}/releases/latest" | jq -r .tag_name)
         [ -n "$VER" ] && [ "$VER" != "null" ] || { echo "FAIL: cannot resolve version"; exit 1; }
         tmp=$(mktemp -d)
-        curl -fL4 -o "${tmp}/x.tar.gz" \
-            "https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"
+        # 504s and other transient GitHub/CDN hiccups are retried; a real HTTP
+        # failure (e.g. missing arch asset) still aborts after the retries.
+        if ! curl -4 --fail --location --silent --show-error \
+            --retry 5 --retry-all-errors --retry-delay 3 \
+            --connect-timeout 15 --max-time 300 \
+            -o "${tmp}/x.tar.gz" \
+            "https://github.com/${REPO}/releases/download/${VER}/x-ui-linux-${ARCH}.tar.gz"; then
+            echo "FAIL: cannot download x-ui-linux-${ARCH}.tar.gz (${VER})" >&2; exit 1
+        fi
+        test -s "${tmp}/x.tar.gz" || { echo "FAIL: downloaded tarball is empty"; exit 1; }
         tar -xzf "${tmp}/x.tar.gz" -C /usr/local/
         chmod +x /usr/local/x-ui/x-ui
         install -m 755 /root/x-ui-firstboot.sh /usr/local/x-ui/x-ui-firstboot.sh

+ 3 - 1
docker-compose.yml

@@ -19,6 +19,7 @@ services:
       XRAY_VMESS_AEAD_FORCED: "false"
       XUI_ENABLE_FAIL2BAN: "true"
       # XUI_INIT_WEB_BASE_PATH: "/"
+      # XUI_PORT: "8080"
       # To use PostgreSQL instead of the default SQLite, run:
       #   docker compose --profile postgres up -d
       # and uncomment the two lines below.
@@ -26,6 +27,7 @@ services:
       # XUI_DB_DSN: "postgres://xui:xui@postgres:5432/xui?sslmode=disable"
     tty: true
     ports:
+      # When XUI_PORT is set, publish the same container port (for example "8080:8080").
       - "2053:2053"
     restart: unless-stopped
 
@@ -39,4 +41,4 @@ services:
       POSTGRES_DB: xui
     volumes:
       - $PWD/pgdata/:/var/lib/postgresql/data
-    restart: unless-stopped
+    restart: unless-stopped

+ 74 - 1
frontend/public/openapi.json

@@ -1621,6 +1621,9 @@
             "example": 3,
             "type": "integer"
           },
+          "outboundTag": {
+            "type": "string"
+          },
           "panelVersion": {
             "example": "v3.x.x",
             "type": "string"
@@ -1708,6 +1711,7 @@
           "memPct",
           "name",
           "onlineCount",
+          "outboundTag",
           "panelVersion",
           "pinnedCertSha256",
           "port",
@@ -4390,7 +4394,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Fetch one client by email, including the inbound IDs it is attached to.",
+        "summary": "Fetch one client by email, including the inbound IDs and external config IDs it is attached to.",
         "operationId": "get_panel_api_clients_get_email",
         "parameters": [
           {
@@ -4719,6 +4723,74 @@
         }
       }
     },
+    "/panel/api/clients/{email}/externalLinks": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Replace a client's external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.",
+        "operationId": "post_panel_api_clients_email_externalLinks",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email (unique identifier).",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "externalLinks": [
+                  {
+                    "kind": "link",
+                    "value": "vless://uuid@host:443?...#srv",
+                    "remark": "DE"
+                  },
+                  {
+                    "kind": "subscription",
+                    "value": "https://provider.example/sub/abc",
+                    "remark": "Provider"
+                  }
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/resetAllTraffics": {
       "post": {
         "tags": [
@@ -6050,6 +6122,7 @@
                       "memPct": 45.1,
                       "name": "de-fra-1",
                       "onlineCount": 3,
+                      "outboundTag": "",
                       "panelVersion": "v3.x.x",
                       "parentGuid": "",
                       "pinnedCertSha256": "",

+ 41 - 3
frontend/src/api/queries/useOutboundTags.ts

@@ -7,7 +7,8 @@ import { fetchXrayConfig } from '@/hooks/useXraySetting';
 // inbound's Telegram traffic to. Shares the cached xray config query so opening
 // the inbound form costs no extra request when the Xray page was already
 // visited; `select` derives just the tag list without disturbing other readers.
-export function useOutboundTags() {
+export function useOutboundTags(opts?: { excludeBlackhole?: boolean }) {
+  const excludeBlackhole = opts?.excludeBlackhole ?? false;
   return useQuery({
     queryKey: keys.xray.config(),
     queryFn: fetchXrayConfig,
@@ -15,8 +16,10 @@ export function useOutboundTags() {
     select: (data): string[] => {
       const tags = new Set<string>();
       for (const o of data?.xraySetting?.outbounds ?? []) {
-        const tag = (o as { tag?: string } | null)?.tag;
-        if (tag) tags.add(tag);
+        const ob = o as { tag?: string; protocol?: string } | null;
+        if (!ob?.tag) continue;
+        if (excludeBlackhole && ob.protocol === 'blackhole') continue;
+        tags.add(ob.tag);
       }
       for (const t of data?.subscriptionOutboundTags ?? []) {
         if (t) tags.add(t);
@@ -31,3 +34,38 @@ export function useOutboundTags() {
     },
   });
 }
+
+export interface OutboundTagGroups {
+  outbounds: string[];
+  balancers: string[];
+}
+
+// Same data as useOutboundTags, but keeps outbound and balancer tags apart so a
+// picker can render them in labeled groups (like the panel-outbound selector)
+// instead of one flat list.
+export function useOutboundTagGroups(opts?: { excludeBlackhole?: boolean }) {
+  const excludeBlackhole = opts?.excludeBlackhole ?? false;
+  return useQuery({
+    queryKey: keys.xray.config(),
+    queryFn: fetchXrayConfig,
+    staleTime: Infinity,
+    select: (data): OutboundTagGroups => {
+      const outbounds = new Set<string>();
+      for (const o of data?.xraySetting?.outbounds ?? []) {
+        const ob = o as { tag?: string; protocol?: string } | null;
+        if (!ob?.tag) continue;
+        if (excludeBlackhole && ob.protocol === 'blackhole') continue;
+        outbounds.add(ob.tag);
+      }
+      for (const t of data?.subscriptionOutboundTags ?? []) {
+        if (t) outbounds.add(t);
+      }
+      const balancers: string[] = [];
+      const bal = (data?.xraySetting?.routing as { balancers?: Array<{ tag?: string }> } | undefined)?.balancers;
+      for (const b of bal ?? []) {
+        if (b?.tag && !outbounds.has(b.tag)) balancers.push(b.tag);
+      }
+      return { outbounds: [...outbounds], balancers };
+    },
+  });
+}

+ 6 - 10
frontend/src/components/form/SelectAllClearButtons.tsx

@@ -1,27 +1,23 @@
 import { useTranslation } from 'react-i18next';
 import { Button } from 'antd';
 
-interface Option {
-  value: number;
-}
-
-interface SelectAllClearButtonsProps {
-  options: Option[];
-  value: number[];
-  onChange: (value: number[]) => void;
+interface SelectAllClearButtonsProps<T extends string | number = number> {
+  options: Array<{ value: T }>;
+  value: T[];
+  onChange: (value: T[]) => void;
   /** Override the default "Select all" label (defaults to the inbound copy). */
   selectAllLabel?: string;
   /** Override the default "Clear all" label (defaults to the inbound copy). */
   clearLabel?: string;
 }
 
-export default function SelectAllClearButtons({
+export default function SelectAllClearButtons<T extends string | number = number>({
   options,
   value,
   onChange,
   selectAllLabel,
   clearLabel,
-}: SelectAllClearButtonsProps) {
+}: SelectAllClearButtonsProps<T>) {
   const { t } = useTranslation();
 
   const optionValues = options.map((o) => o.value);

+ 1 - 0
frontend/src/generated/examples.ts

@@ -354,6 +354,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "memPct": 45.1,
     "name": "de-fra-1",
     "onlineCount": 3,
+    "outboundTag": "",
     "panelVersion": "v3.x.x",
     "parentGuid": "",
     "pinnedCertSha256": "",

+ 4 - 0
frontend/src/generated/schemas.ts

@@ -1595,6 +1595,9 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 3,
         "type": "integer"
       },
+      "outboundTag": {
+        "type": "string"
+      },
       "panelVersion": {
         "example": "v3.x.x",
         "type": "string"
@@ -1682,6 +1685,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "memPct",
       "name",
       "onlineCount",
+      "outboundTag",
       "panelVersion",
       "pinnedCertSha256",
       "port",

+ 1 - 0
frontend/src/generated/types.ts

@@ -358,6 +358,7 @@ export interface Node {
   memPct: number;
   name: string;
   onlineCount: number;
+  outboundTag: string;
   panelVersion: string;
   parentGuid?: string;
   pinnedCertSha256: string;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -384,6 +384,7 @@ export const NodeSchema = z.object({
   memPct: z.number(),
   name: z.string(),
   onlineCount: z.number().int(),
+  outboundTag: z.string(),
   panelVersion: z.string(),
   parentGuid: z.string().optional(),
   pinnedCertSha256: z.string(),

+ 17 - 1
frontend/src/hooks/useClients.ts

@@ -22,6 +22,7 @@ import {
   type ClientsSummary,
   type ClientPageResponse,
   type InboundOption,
+  type ExternalLink,
   type BulkAdjustResult,
   type BulkAttachResult,
   type BulkCreateResult,
@@ -30,7 +31,10 @@ import {
 } from '@/schemas/client';
 import { DefaultsPayloadSchema } from '@/schemas/defaults';
 
-export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption };
+// One row sent to POST /clients/:email/externalLinks.
+export type ExternalLinkInput = { kind: 'link' | 'subscription'; value: string; remark: string };
+
+export type { ClientRecord, ClientTraffic, ClientsSummary, InboundOption, ExternalLink };
 
 const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
 
@@ -350,6 +354,12 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
+  const setExternalLinksMut = useMutation({
+    mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
+      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
   const bulkAttachMut = useMutation({
     mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkAttachResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS);
@@ -364,6 +374,7 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
+
   const bulkDetachMut = useMutation({
     mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise<Msg<BulkDetachResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS);
@@ -424,6 +435,10 @@ export function useClients() {
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return attachMut.mutateAsync({ email, inboundIds });
   }, [attachMut]);
+  const setExternalLinks = useCallback((email: string, externalLinks: ExternalLinkInput[]) => {
+    if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
+    return setExternalLinksMut.mutateAsync({ email, externalLinks });
+  }, [setExternalLinksMut]);
   const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
     if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg<BulkAttachResult>);
@@ -553,6 +568,7 @@ export function useClients() {
     bulkAddToGroup,
     bulkRemoveFromGroup,
     attach,
+    setExternalLinks,
     bulkAttach,
     detach,
     bulkDetach,

+ 178 - 17
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

@@ -4,7 +4,9 @@ import type { FormInstance } from 'antd/es/form';
 import type { NamePath } from 'antd/es/form/interface';
 
 import { RandomUtil } from '@/utils';
-import { OutboundProtocols } from '@/schemas/primitives';
+import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
+
+const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value }));
 
 export interface FinalMaskFormProps {
   name: NamePath;
@@ -18,6 +20,46 @@ export interface FinalMaskFormProps {
 }
 
 const TCP_NETWORKS = ['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'];
+const DEFAULT_GECKO_PACKET_SIZE = { min: 512, max: 1200 };
+// Xray-core caps the Gecko output packet size at its internal buffer (2048)
+// and needs 1 <= min <= max; mirror those bounds so the panel rejects what
+// core would reject at runtime (salamander/conn.go).
+const GECKO_MIN_PACKET_SIZE = 1;
+const GECKO_MAX_PACKET_SIZE = 2048;
+
+export function parseGeckoPacketSize(value: unknown): { min: number; max: number } | null {
+  const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
+  const match = /^(\d+)-(\d+)$/.exec(str);
+  if (!match) return null;
+  const min = Number(match[1]);
+  const max = Number(match[2]);
+  if (
+    !Number.isSafeInteger(min) || !Number.isSafeInteger(max)
+    || min < GECKO_MIN_PACKET_SIZE || max < min || max > GECKO_MAX_PACKET_SIZE
+  ) {
+    return null;
+  }
+  return { min, max };
+}
+
+function formatGeckoPacketSize(min: number, max: number): string {
+  return `${min}-${max}`;
+}
+
+function splitGeckoPacketSize(value: unknown): { min: number | null; max: number | null } {
+  const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
+  const [minRaw = '', maxRaw = ''] = str.split('-', 2);
+  const min = /^\d+$/.test(minRaw) ? Number(minRaw) : null;
+  const max = /^\d+$/.test(maxRaw) ? Number(maxRaw) : null;
+  return { min, max };
+}
+
+function validateGeckoPacketSize(_rule: unknown, value: unknown): Promise<void> {
+  if (parseGeckoPacketSize(value)) return Promise.resolve();
+  return Promise.reject(new Error(
+    `Use a range like 512-1200 (${GECKO_MIN_PACKET_SIZE}-${GECKO_MAX_PACKET_SIZE}, max ≥ min)`,
+  ));
+}
 
 function asPath(name: NamePath): (string | number)[] {
   return Array.isArray(name) ? [...name] : [name];
@@ -470,22 +512,7 @@ function UdpMaskItem({
         {({ getFieldValue }) => {
           const type = getFieldValue([...absolutePath, 'type']) as string | undefined;
           if (type === 'salamander') {
-            return (
-              <Form.Item label="Password">
-                <Space.Compact block>
-                  <Form.Item name={[fieldName, 'settings', 'password']} noStyle>
-                    <Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
-                  </Form.Item>
-                  <Button
-                    icon={<ReloadOutlined />}
-                    onClick={() => form.setFieldValue(
-                      [...absolutePath, 'settings', 'password'],
-                      RandomUtil.randomLowerAndNum(16),
-                    )}
-                  />
-                </Space.Compact>
-              </Form.Item>
-            );
+            return <SalamanderUdpMaskSettings fieldName={fieldName} form={form} absolutePath={absolutePath} />;
           }
           if (type === 'mkcp-legacy') {
             return (
@@ -537,6 +564,35 @@ function UdpMaskItem({
                 <Form.Item label="STUN Servers" name={[fieldName, 'settings', 'stunServers']}>
                   <Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} placeholder="host:port" />
                 </Form.Item>
+                <Divider plain style={{ margin: '8px 0' }}>TLS (optional)</Divider>
+                <Form.Item label="Server Name" name={[fieldName, 'settings', 'tlsConfig', 'serverName']}>
+                  <Input placeholder="SNI for the realm server (leave empty to skip TLS)" />
+                </Form.Item>
+                <Form.Item label="ALPN" name={[fieldName, 'settings', 'tlsConfig', 'alpn']}>
+                  <Select
+                    mode="multiple"
+                    style={{ width: '100%' }}
+                    options={[
+                      { value: 'h3', label: 'h3' },
+                      { value: 'h2', label: 'h2' },
+                      { value: 'http/1.1', label: 'http/1.1' },
+                    ]}
+                  />
+                </Form.Item>
+                <Form.Item label="Fingerprint" name={[fieldName, 'settings', 'tlsConfig', 'fingerprint']}>
+                  <Select
+                    allowClear
+                    style={{ width: '100%' }}
+                    options={UTLS_FINGERPRINT_OPTIONS}
+                  />
+                </Form.Item>
+                <Form.Item
+                  label="Allow Insecure"
+                  name={[fieldName, 'settings', 'tlsConfig', 'allowInsecure']}
+                  valuePropName="checked"
+                >
+                  <Switch />
+                </Form.Item>
               </>
             );
           }
@@ -565,6 +621,111 @@ function UdpMaskItem({
   );
 }
 
+function SalamanderUdpMaskSettings({
+  fieldName, form, absolutePath,
+}: {
+  fieldName: number;
+  form: FormInstance;
+  absolutePath: (string | number)[];
+}) {
+  const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
+  const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
+  const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
+
+  return (
+    <>
+      <Form.Item
+        label="Mode"
+        extra={mode === 'gecko'
+          ? 'Salamander plus Gecko: splits each packet into random-padded fragments sized within the range below, defeating packet-length fingerprinting. Stored as Salamander with packetSize.'
+          : 'Scrambles each packet into random-looking bytes.'}
+      >
+        <Select
+          value={mode}
+          onChange={(next) => {
+            if (next === 'gecko') {
+              const current = form.getFieldValue(packetSizePath);
+              form.setFieldValue(
+                packetSizePath,
+                parseGeckoPacketSize(current)
+                  ? current
+                  : formatGeckoPacketSize(DEFAULT_GECKO_PACKET_SIZE.min, DEFAULT_GECKO_PACKET_SIZE.max),
+              );
+            } else {
+              form.setFieldValue(packetSizePath, undefined);
+            }
+          }}
+          options={[
+            { value: 'salamander', label: 'Salamander' },
+            { value: 'gecko', label: 'Gecko experimental' },
+          ]}
+        />
+      </Form.Item>
+
+      <Form.Item label="Password">
+        <Space.Compact block>
+          <Form.Item name={[fieldName, 'settings', 'password']} noStyle>
+            <Input placeholder="Obfuscation password" style={{ width: 'calc(100% - 32px)' }} />
+          </Form.Item>
+          <Button
+            icon={<ReloadOutlined />}
+            onClick={() => form.setFieldValue(
+              [...absolutePath, 'settings', 'password'],
+              RandomUtil.randomLowerAndNum(16),
+            )}
+          />
+        </Space.Compact>
+      </Form.Item>
+
+      {mode === 'gecko' && (
+        <Form.Item
+          label="Packet size"
+          name={[fieldName, 'settings', 'packetSize']}
+          rules={[{ validator: validateGeckoPacketSize }]}
+          extra="Serialized as a string range, for example 512-1200."
+        >
+          <GeckoPacketSizeInput />
+        </Form.Item>
+      )}
+    </>
+  );
+}
+
+function GeckoPacketSizeInput({
+  value,
+  onChange,
+}: {
+  value?: string;
+  onChange?: (value: string) => void;
+}) {
+  const { min, max } = splitGeckoPacketSize(value);
+
+  return (
+    <Space.Compact block>
+      <InputNumber
+        addonBefore="Min"
+        min={GECKO_MIN_PACKET_SIZE}
+        max={GECKO_MAX_PACKET_SIZE}
+        precision={0}
+        value={min}
+        placeholder={String(DEFAULT_GECKO_PACKET_SIZE.min)}
+        onChange={(next) => onChange?.(`${next ?? ''}-${max ?? ''}`)}
+        style={{ width: '50%' }}
+      />
+      <InputNumber
+        addonBefore="Max"
+        min={GECKO_MIN_PACKET_SIZE}
+        max={GECKO_MAX_PACKET_SIZE}
+        precision={0}
+        value={max}
+        placeholder={String(DEFAULT_GECKO_PACKET_SIZE.max)}
+        onChange={(next) => onChange?.(`${min ?? ''}-${next ?? ''}`)}
+        style={{ width: '50%' }}
+      />
+    </Space.Compact>
+  );
+}
+
 function UdpHeaderCustom({
   udpFieldName, form, absoluteSettingsPath,
 }: {

+ 13 - 5
frontend/src/lib/xray/inbound-link.ts

@@ -23,6 +23,14 @@ import { getHeaderValue } from './headers';
 type ForceTls = 'same' | 'tls' | 'none';
 const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/;
 
+// Format a host for interpolation into a URL authority. IPv6 literals are
+// wrapped in square brackets per RFC 3986; IPv4 and hostnames are left as-is.
+// Any brackets already present are first stripped so the helper is idempotent.
+function formatUrlHost(address: string): string {
+  const bare = address.replace(/^\[|\]$/g, '');
+  return bare.includes(':') ? `[${bare}]` : bare;
+}
+
 // xHTTP headers ship as Record<string, string> on the wire (Zod schema)
 // rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
 function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
@@ -400,7 +408,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
     params.set('security', 'none');
   }
 
-  const url = new URL(`vless://${clientId}@${address}:${port}`);
+  const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
   for (const [key, value] of params) url.searchParams.set(key, value);
   url.hash = encodeURIComponent(remark);
   return url.toString();
@@ -524,7 +532,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
     params.set('security', 'none');
   }
 
-  const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`);
+  const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`);
   for (const [key, value] of params) url.searchParams.set(key, value);
   url.hash = encodeURIComponent(remark);
   return url.toString();
@@ -583,7 +591,7 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
   if (isSSMultiUser) passwords.push(clientPassword);
 
   const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
-  const url = new URL(`ss://${userinfo}@${address}:${port}`);
+  const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`);
   for (const [key, value] of params) url.searchParams.set(key, value);
   url.hash = encodeURIComponent(remark);
   return url.toString();
@@ -681,7 +689,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
     params.set('mport', hopPorts);
   }
 
-  const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`);
+  const url = new URL(`${scheme}://${clientAuth}@${formatUrlHost(address)}:${port}`);
   for (const [key, value] of params) url.searchParams.set(key, value);
   url.hash = encodeURIComponent(remark);
   return url.toString();
@@ -724,7 +732,7 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
   const peer = settings.peers[peerIndex];
   if (!peer) return '';
 
-  const url = new URL(`wireguard://${address}:${port}`);
+  const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`);
   url.username = peer.privateKey ?? '';
 
   const pubKey = settings.secretKey.length > 0

+ 13 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -503,12 +503,12 @@ export const sections: readonly Section[] = [
       {
         method: 'GET',
         path: '/panel/api/clients/get/:email',
-        summary: 'Fetch one client by email, including the inbound IDs it is attached to.',
+        summary: 'Fetch one client by email, including the inbound IDs and external config IDs it is attached to.',
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5]\n  }\n}',
+          '{\n  "success": true,\n  "obj": {\n    "client": { "id": 1, "email": "[email protected]", ... },\n    "inboundIds": [3, 5],\n    "externalLinks": [{ "kind": "link", "value": "vless://...", "remark": "DE" }]\n  }\n}',
       },
       {
         method: 'POST',
@@ -563,6 +563,17 @@ export const sections: readonly Section[] = [
         body: '{\n  "inboundIds": [5]\n}',
         response: '{\n  "success": true\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/:email/externalLinks',
+        summary: 'Replace a client\'s external links (per-client share links and remote subscription URLs surfaced in their subscription). Sends the full set; the server replaces all rows.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email (unique identifier).' },
+          { name: 'externalLinks', in: 'body (json)', type: 'object[]', desc: 'Rows of { kind: "link" | "subscription", value, remark }. kind=link must be a share link; kind=subscription must be an http(s) URL.' },
+        ],
+        body: '{\n  "externalLinks": [\n    { "kind": "link", "value": "vless://uuid@host:443?...#srv", "remark": "DE" },\n    { "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider" }\n  ]\n}',
+        response: '{\n  "success": true\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/resetAllTraffics',

+ 108 - 3
frontend/src/pages/clients/ClientFormModal.tsx

@@ -16,16 +16,17 @@ import {
   Tabs,
   Tag,
   Tooltip,
+  Typography,
   message,
 } from 'antd';
-import { EyeOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
+import { DeleteOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, RetweetOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
 import { HttpUtil, RandomUtil } from '@/utils';
 import { formatInboundLabel } from '@/lib/inbounds/label';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
-import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
 import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -38,6 +39,13 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
 const CLIENT_FORM_MODAL_Z_INDEX = 1000;
 const CLIENT_IP_LOG_MODAL_Z_INDEX = CLIENT_FORM_MODAL_Z_INDEX + 1;
 
+// One editable row in the Links tab. `key` is a stable client-side id for React.
+interface ExternalLinkRow {
+  key: number;
+  kind: 'link' | 'subscription';
+  value: string;
+}
+
 interface ApiMsg<T = unknown> {
   success?: boolean;
   msg?: string;
@@ -51,10 +59,13 @@ interface SaveMetaEdit {
   email: string;
   attach: number[];
   detach: number[];
+  externalLinks: ExternalLinkInput[];
 }
 
 interface SaveMetaCreate {
   isEdit: false;
+  email: string;
+  externalLinks: ExternalLinkInput[];
 }
 
 interface SaveCreatePayload {
@@ -67,6 +78,7 @@ interface ClientFormModalProps {
   mode: Mode;
   client: ClientRecord | null;
   inbounds: InboundOption[];
+  attachedExternalLinks?: ExternalLink[];
   attachedIds?: number[];
   tgBotEnable?: boolean;
   groups?: string[];
@@ -98,6 +110,7 @@ interface FormState {
   comment: string;
   enable: boolean;
   inboundIds: number[];
+  externalLinks: ExternalLinkRow[];
 }
 
 function emptyForm(): FormState {
@@ -121,9 +134,19 @@ function emptyForm(): FormState {
     comment: '',
     enable: true,
     inboundIds: [],
+    externalLinks: [],
   };
 }
 
+let externalLinkRowSeq = 0;
+function toExternalLinkRows(links: ExternalLink[] | undefined): ExternalLinkRow[] {
+  return (links || []).map((l) => ({
+    key: (externalLinkRowSeq += 1),
+    kind: l.kind === 'subscription' ? 'subscription' : 'link',
+    value: l.value || '',
+  }));
+}
+
 function bytesToGB(bytes: number): number {
   if (!bytes || bytes <= 0) return 0;
   return Math.round((bytes / (1024 * 1024 * 1024)) * 100) / 100;
@@ -139,6 +162,7 @@ export default function ClientFormModal({
   mode,
   client,
   inbounds,
+  attachedExternalLinks = [],
   attachedIds = [],
   tgBotEnable = false,
   groups = [],
@@ -162,6 +186,27 @@ export default function ClientFormModal({
     setForm((prev) => ({ ...prev, [key]: value }));
   }
 
+  function addExternalLinkRow(kind: 'link' | 'subscription') {
+    setForm((prev) => ({
+      ...prev,
+      externalLinks: [...prev.externalLinks, { key: (externalLinkRowSeq += 1), kind, value: '' }],
+    }));
+  }
+
+  function updateExternalLinkRow(key: number, value: string) {
+    setForm((prev) => ({
+      ...prev,
+      externalLinks: prev.externalLinks.map((r) => (r.key === key ? { ...r, value } : r)),
+    }));
+  }
+
+  function removeExternalLinkRow(key: number) {
+    setForm((prev) => ({
+      ...prev,
+      externalLinks: prev.externalLinks.filter((r) => r.key !== key),
+    }));
+  }
+
   useEffect(() => {
     if (!open) return;
     setIpsModalOpen(false);
@@ -186,6 +231,7 @@ export default function ClientFormModal({
         comment: client.comment || '',
         enable: !!client.enable,
         inboundIds: Array.isArray(attachedIds) ? [...attachedIds] : [],
+        externalLinks: toExternalLinkRows(attachedExternalLinks),
       };
       if (et < 0) {
         next.delayedStart = true;
@@ -300,6 +346,9 @@ export default function ClientFormModal({
     [inbounds],
   );
 
+  const linkRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'link'), [form.externalLinks]);
+  const subscriptionRows = useMemo(() => form.externalLinks.filter((r) => r.kind === 'subscription'), [form.externalLinks]);
+
   async function loadIps() {
     if (!isEdit || !client?.email) return;
     setIpsLoading(true);
@@ -400,6 +449,10 @@ export default function ClientFormModal({
       clientPayload.reverse = { tag: reverseTag };
     }
 
+    const externalLinks: ExternalLinkInput[] = form.externalLinks
+      .map((r) => ({ kind: r.kind, value: r.value.trim(), remark: '' }))
+      .filter((r) => r.value !== '');
+
     setSubmitting(true);
     try {
       let msg;
@@ -413,11 +466,12 @@ export default function ClientFormModal({
           email: client.email,
           attach: toAttach,
           detach: toDetach,
+          externalLinks,
         });
       } else {
         msg = await save(
           { client: clientPayload, inboundIds: form.inboundIds },
-          { isEdit: false },
+          { isEdit: false, email: clientPayload.email as string, externalLinks },
         );
       }
       if (msg?.success) close();
@@ -692,6 +746,57 @@ export default function ClientFormModal({
                   </>
                 ),
               },
+              {
+                key: 'links',
+                label: t('pages.clients.tabLinks'),
+                children: (
+                  <>
+                    <Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
+                      {t('pages.clients.linksHint')}
+                    </Typography.Paragraph>
+
+                    <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('link')}>
+                      {t('pages.clients.addExternalLink')}
+                    </Button>
+                    <div style={{ marginTop: 12, marginBottom: 24 }}>
+                      {linkRows.length === 0 ? (
+                        <Typography.Text type="secondary">{t('pages.clients.noExternalLinks')}</Typography.Text>
+                      ) : linkRows.map((row) => (
+                        <div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
+                          <Input
+                            value={row.value}
+                            onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
+                            placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
+                          />
+                          <Tooltip title={t('delete')}>
+                            <Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
+                          </Tooltip>
+                        </div>
+                      ))}
+                    </div>
+
+                    <Button type="primary" icon={<PlusOutlined />} onClick={() => addExternalLinkRow('subscription')}>
+                      {t('pages.clients.addExternalSubscription')}
+                    </Button>
+                    <div style={{ marginTop: 12 }}>
+                      {subscriptionRows.length === 0 ? (
+                        <Typography.Text type="secondary">{t('pages.clients.noExternalSubscriptions')}</Typography.Text>
+                      ) : subscriptionRows.map((row) => (
+                        <div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
+                          <Input
+                            value={row.value}
+                            onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
+                            placeholder="https://provider.example/sub/…"
+                          />
+                          <Tooltip title={t('delete')}>
+                            <Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
+                          </Tooltip>
+                        </div>
+                      ))}
+                    </div>
+                  </>
+                ),
+              },
             ]}
           />
         </Form>

+ 20 - 5
frontend/src/pages/clients/ClientsPage.tsx

@@ -53,7 +53,7 @@ import { useWebSocket } from '@/hooks/useWebSocket';
 import { useClients } from '@/hooks/useClients';
 import { useNodesQuery } from '@/api/queries/useNodesQuery';
 import { useDatepicker } from '@/hooks/useDatepicker';
-import type { ClientRecord, InboundOption } from '@/hooks/useClients';
+import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
 import ClientTrafficCell from '@/components/clients/ClientTrafficCell';
 import AppSidebar from '@/layouts/AppSidebar';
 import { IntlUtil, SizeFormatter } from '@/utils';
@@ -199,7 +199,7 @@ export default function ClientsPage() {
     setQuery,
     inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
     tgBotEnable, expireDiff, trafficDiff, pageSize,
-    create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
+    create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
     refresh,
@@ -220,6 +220,7 @@ export default function ClientsPage() {
   const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
   const [editingClient, setEditingClient] = useState<ClientRecord | null>(null);
   const [editingAttachedIds, setEditingAttachedIds] = useState<number[]>([]);
+  const [editingExternalLinks, setEditingExternalLinks] = useState<ExternalLink[]>([]);
   const [infoOpen, setInfoOpen] = useState(false);
   const [infoClient, setInfoClient] = useState<ClientRecord | null>(null);
   const [qrOpen, setQrOpen] = useState(false);
@@ -429,6 +430,7 @@ export default function ClientsPage() {
     setFormMode('add');
     setEditingClient(null);
     setEditingAttachedIds([]);
+    setEditingExternalLinks([]);
     setFormOpen(true);
   }
 
@@ -441,6 +443,7 @@ export default function ClientsPage() {
     setEditingClient(merged);
     const ids = full?.inboundIds ?? (Array.isArray(row.inboundIds) ? row.inboundIds : []);
     setEditingAttachedIds([...ids]);
+    setEditingExternalLinks(Array.isArray(full?.externalLinks) ? [...full.externalLinks] : []);
     setFormOpen(true);
   }
 
@@ -567,10 +570,18 @@ export default function ClientsPage() {
 
   const onSave = useCallback(async (
     payload: Record<string, unknown> | { client: Record<string, unknown>; inboundIds: number[] },
-    meta: { isEdit: false } | { isEdit: true; email: string; attach: number[]; detach: number[] },
+    meta:
+      | { isEdit: false; email: string; externalLinks: ExternalLinkInput[] }
+      | { isEdit: true; email: string; attach: number[]; detach: number[]; externalLinks: ExternalLinkInput[] },
   ) => {
     if (!meta.isEdit) {
-      return create(payload);
+      const createMsg = await create(payload);
+      if (!createMsg?.success) return createMsg;
+      if (meta.email && meta.externalLinks.length > 0) {
+        const r = await setExternalLinks(meta.email, meta.externalLinks);
+        if (!r?.success) return r;
+      }
+      return createMsg;
     }
     const updateMsg = await update(meta.email, payload);
     if (!updateMsg?.success) return updateMsg;
@@ -582,8 +593,11 @@ export default function ClientsPage() {
       const r = await detach(meta.email, meta.detach);
       if (!r?.success) return r;
     }
+    // Always replace the client's external links (an empty set clears them).
+    const r = await setExternalLinks(meta.email, meta.externalLinks);
+    if (!r?.success) return r;
     return updateMsg;
-  }, [create, update, attach, detach]);
+  }, [create, update, attach, detach, setExternalLinks]);
 
   const pageClass = useMemo(() => {
     const classes = ['clients-page'];
@@ -1243,6 +1257,7 @@ export default function ClientsPage() {
             mode={formMode}
             client={editingClient}
             attachedIds={editingAttachedIds}
+            attachedExternalLinks={editingExternalLinks}
             inbounds={inbounds}
             tgBotEnable={tgBotEnable}
             groups={allGroups}

+ 2 - 0
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -82,6 +82,7 @@ export default function InboundsPage() {
     clientCount,
     onlineClients,
     lastOnlineMap,
+    inboundSpeed,
     totals,
     expireDiff,
     trafficDiff,
@@ -620,6 +621,7 @@ export default function InboundsPage() {
                       clientCount={clientCount}
                       onlineClients={onlineClients}
                       lastOnlineMap={lastOnlineMap}
+                      inboundSpeed={inboundSpeed}
                       expireDiff={expireDiff}
                       trafficDiff={trafficDiff}
                       pageSize={pageSize}

+ 3 - 0
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -36,6 +36,7 @@ export default function InboundList({
   dbInbounds,
   clientCount,
   lastOnlineMap: _lastOnlineMap,
+  inboundSpeed,
   expireDiff,
   trafficDiff,
   pageSize,
@@ -124,6 +125,7 @@ export default function InboundList({
     hasActiveNode,
     nodesById,
     clientCount,
+    inboundSpeed,
     subEnable,
     expireDiff,
     trafficDiff,
@@ -271,6 +273,7 @@ export default function InboundList({
         hasActiveNode={hasActiveNode}
         nodesById={nodesById}
         clientCount={clientCount}
+        inboundSpeed={inboundSpeed}
         trafficDiff={trafficDiff}
         expireDiff={expireDiff}
         onClose={() => setStatsRecord(null)}

+ 39 - 0
frontend/src/pages/inbounds/list/InboundSpeedTag.tsx

@@ -0,0 +1,39 @@
+import { Tag, Tooltip } from 'antd';
+
+import { SizeFormatter } from '@/utils';
+
+import type { InboundSpeedEntry } from './types';
+
+// True when an inbound has live throughput worth showing.
+export function isActiveSpeed(speed?: InboundSpeedEntry): speed is InboundSpeedEntry {
+  return !!speed && (speed.up > 0 || speed.down > 0);
+}
+
+interface InboundSpeedTagProps {
+  speed: InboundSpeedEntry;
+  withTooltip?: boolean;
+}
+
+// Blue "↑ up / ↓ down" rate tag, optionally with a stacked breakdown tooltip.
+export function InboundSpeedTag({ speed, withTooltip = false }: InboundSpeedTagProps) {
+  const tag = (
+    <Tag color="blue">
+      ↑ {SizeFormatter.speedFormat(speed.up)}
+      {' / '}
+      ↓ {SizeFormatter.speedFormat(speed.down)}
+    </Tag>
+  );
+  if (!withTooltip) return tag;
+  return (
+    <Tooltip
+      title={(
+        <div>
+          <div>↑ {SizeFormatter.speedFormat(speed.up)}</div>
+          <div>↓ {SizeFormatter.speedFormat(speed.down)}</div>
+        </div>
+      )}
+    >
+      {tag}
+    </Tooltip>
+  );
+}

+ 14 - 1
frontend/src/pages/inbounds/list/InboundStatsModal.tsx

@@ -13,7 +13,8 @@ import {
   tunnelNetworkLabel,
   mixedNetworkLabel,
 } from './helpers';
-import type { ClientCountEntry, DBInboundRecord } from './types';
+import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
+import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry } from './types';
 
 interface InboundStatsModalProps {
   open: boolean;
@@ -21,6 +22,7 @@ interface InboundStatsModalProps {
   hasActiveNode: boolean;
   nodesById: Map<number, NodeRecord>;
   clientCount: Record<number, ClientCountEntry>;
+  inboundSpeed: Record<number, InboundSpeedEntry>;
   trafficDiff: number;
   expireDiff: number;
   onClose: () => void;
@@ -32,6 +34,7 @@ export default function InboundStatsModal({
   hasActiveNode,
   nodesById,
   clientCount,
+  inboundSpeed,
   trafficDiff,
   expireDiff,
   onClose,
@@ -109,6 +112,16 @@ export default function InboundStatsModal({
               {record.total > 0 ? SizeFormatter.sizeFormat(record.total) : <InfinityIcon />}
             </Tag>
           </div>
+          {(() => {
+            const speed = inboundSpeed[record.id];
+            if (!isActiveSpeed(speed)) return null;
+            return (
+              <div className="stat-row">
+                <span className="stat-label">{t('pages.inbounds.speed')}</span>
+                <InboundSpeedTag speed={speed} />
+              </div>
+            );
+          })()}
           {clientCount[record.id] && (
             <div className="stat-row">
               <span className="stat-label">{t('clients')}</span>

+ 6 - 0
frontend/src/pages/inbounds/list/types.ts

@@ -44,6 +44,11 @@ export interface ClientCountEntry {
   online: string[];
 }
 
+export interface InboundSpeedEntry {
+  up: number;
+  down: number;
+}
+
 export type RowAction =
   | 'edit'
   | 'showInfo'
@@ -63,6 +68,7 @@ export interface InboundListProps {
   clientCount: Record<number, ClientCountEntry>;
   onlineClients: string[];
   lastOnlineMap: Record<string, number>;
+  inboundSpeed: Record<number, InboundSpeedEntry>;
   expireDiff: number;
   trafficDiff: number;
   pageSize: number;

+ 18 - 2
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -9,6 +9,7 @@ import { useDatepicker } from '@/hooks/useDatepicker';
 import type { NodeRecord } from '@/api/queries/useNodesQuery';
 
 import { RowActionsCell } from './RowActions';
+import { InboundSpeedTag, isActiveSpeed } from './InboundSpeedTag';
 import {
   readStreamHints,
   networkLabel,
@@ -17,7 +18,7 @@ import {
   tunnelNetworkLabel,
   mixedNetworkLabel,
 } from './helpers';
-import type { ClientCountEntry, DBInboundRecord, RowAction } from './types';
+import type { ClientCountEntry, DBInboundRecord, InboundSpeedEntry, RowAction } from './types';
 
 interface UseInboundColumnsParams {
   hasAnyRemark: boolean;
@@ -25,6 +26,7 @@ interface UseInboundColumnsParams {
   hasActiveNode: boolean;
   nodesById: Map<number, NodeRecord>;
   clientCount: Record<number, ClientCountEntry>;
+  inboundSpeed: Record<number, InboundSpeedEntry>;
   subEnable: boolean;
   expireDiff: number;
   trafficDiff: number;
@@ -38,6 +40,7 @@ export function useInboundColumns({
   hasActiveNode,
   nodesById,
   clientCount,
+  inboundSpeed,
   subEnable,
   expireDiff,
   trafficDiff,
@@ -262,6 +265,19 @@ export function useInboundColumns({
           </Popover>
         ),
       },
+      {
+        title: t('pages.inbounds.speed'),
+        key: 'speed',
+        align: 'center',
+        width: 90,
+        render: (_, record) => {
+          const speed = inboundSpeed[record.id];
+          if (!isActiveSpeed(speed)) {
+            return <Tag color='default'>—</Tag>;
+          }
+          return <InboundSpeedTag speed={speed} withTooltip />;
+        },
+      },
       {
         title: t('pages.inbounds.expireDate'),
         key: 'expiryTime',
@@ -283,5 +299,5 @@ export function useInboundColumns({
     );
 
     return cols;
-  }, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
+  }, [t, hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, inboundSpeed, subEnable, expireDiff, trafficDiff, datepicker, onRowAction, onSwitchEnable]);
 }

+ 43 - 1
frontend/src/pages/inbounds/useInbounds.ts

@@ -12,6 +12,8 @@ import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from
 import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@/schemas/client';
 import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
 
+import type { InboundSpeedEntry } from './list/types';
+
 export interface SubSettings {
   enable: boolean;
   subTitle: string;
@@ -26,6 +28,17 @@ export interface SubSettings {
 
 type DBInboundInstance = InstanceType<typeof DBInbound>;
 
+// Server-side traffic polling interval in seconds. XrayTrafficJob broadcasts
+// deltas accumulated over this window, so dividing by it yields bytes/sec.
+const TRAFFIC_POLL_INTERVAL_S = 5;
+
+interface TrafficDelta {
+  Tag: string;
+  Up: number;
+  Down: number;
+  IsInbound?: boolean;
+}
+
 interface ClientRollup {
   clients: number;
   active: string[];
@@ -179,6 +192,8 @@ export function useInbounds() {
   const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
   const [statsVersion, setStatsVersion] = useState(0);
 
+  const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>({});
+
   const [onlineClients, setOnlineClients] = useState<string[]>([]);
   const onlineClientsRef = useRef<string[]>([]);
   onlineClientsRef.current = onlineClients;
@@ -383,7 +398,13 @@ export function useInbounds() {
   const applyTrafficEvent = useCallback(
     (payload: unknown) => {
       if (!payload || typeof payload !== 'object') return;
-      const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
+      const p = payload as {
+        traffics?: TrafficDelta[];
+        onlineClients?: string[];
+        onlineByGuid?: Record<string, string[]>;
+        activeInbounds?: Record<string, string[]>;
+        lastOnlineMap?: Record<string, number>;
+      };
       if (Array.isArray(p.onlineClients)) {
         onlineClientsRef.current = p.onlineClients;
         setOnlineClients(p.onlineClients);
@@ -397,6 +418,26 @@ export function useInbounds() {
       if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
         setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
       }
+      // Full-replace each poll so idle inbounds (and an empty array after an
+      // Xray stat reset) clear their speed instead of showing a stale value.
+      if (Array.isArray(p.traffics)) {
+        const byTag = new Map<string, TrafficDelta>();
+        for (const tr of p.traffics) {
+          if (!tr || typeof tr.Tag !== 'string') continue;
+          if (tr.IsInbound === false) continue;
+          byTag.set(tr.Tag, tr);
+        }
+        const nextSpeed: Record<number, InboundSpeedEntry> = {};
+        for (const ib of dbInboundsRef.current) {
+          const delta = byTag.get(ib.tag);
+          if (!delta) continue;
+          nextSpeed[ib.id] = {
+            up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
+            down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
+          };
+        }
+        setInboundSpeed(nextSpeed);
+      }
       rebuildClientCount();
     },
     [rebuildClientCount],
@@ -481,6 +522,7 @@ export function useInbounds() {
     clientCount,
     onlineClients,
     lastOnlineMap,
+    inboundSpeed,
     statsVersion,
     totals,
     expireDiff,

+ 34 - 0
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -18,6 +18,7 @@ import type { RemoteInboundOption } from '@/api/queries/useNodeMutations';
 import type { Msg } from '@/utils';
 import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas/node';
 import { antdRule } from '@/utils/zodForm';
+import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
 import './NodeFormModal.css';
 
 type Mode = 'add' | 'edit';
@@ -49,6 +50,7 @@ function defaultValues(): NodeFormValues {
     pinnedCertSha256: '',
     inboundSyncMode: 'all',
     inboundTags: [],
+    outboundTag: '',
   };
 }
 
@@ -75,6 +77,23 @@ export default function NodeFormModal({
   const scheme = Form.useWatch('scheme', form) ?? 'https';
   const tlsVerifyMode = Form.useWatch('tlsVerifyMode', form) ?? 'verify';
   const inboundSyncMode = Form.useWatch('inboundSyncMode', form) ?? 'all';
+  const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
+
+  // Outbounds and balancers share one picker (like the panel-outbound selector);
+  // when balancers exist they get a labeled group so it's clear the selection
+  // routes through a balancer. Empty falls back to the placeholder ("Direct
+  // connection") rather than a synthetic option, so it can't read as a second
+  // "direct" next to a real freedom outbound.
+  const outboundOptions = useMemo<
+    ({ label: string; value: string } | { label: string; options: { label: string; value: string }[] })[]
+  >(() => {
+    const outOpts = (outboundGroups?.outbounds ?? []).map((tag) => ({ label: tag, value: tag }));
+    if (!outboundGroups?.balancers.length) return outOpts;
+    return [
+      { label: t('pages.xray.Outbounds'), options: outOpts },
+      { label: t('pages.xray.Balancers'), options: outboundGroups.balancers.map((tag) => ({ label: tag, value: tag })) },
+    ];
+  }, [outboundGroups, t]);
 
   useEffect(() => {
     if (!open) return;
@@ -117,6 +136,7 @@ export default function NodeFormModal({
       pinnedCertSha256: values.tlsVerifyMode === 'pin' ? values.pinnedCertSha256.trim() : '',
       inboundSyncMode: values.inboundSyncMode,
       inboundTags: values.inboundSyncMode === 'selected' ? values.inboundTags : [],
+      outboundTag: values.outboundTag || '',
     };
   }
 
@@ -356,6 +376,20 @@ export default function NodeFormModal({
             <Input.Password placeholder={t('pages.nodes.apiTokenPlaceholder')} />
           </Form.Item>
 
+          <Form.Item
+            label={t('pages.nodes.outboundTag')}
+            name="outboundTag"
+            extra={t('pages.nodes.outboundTagHint')}
+            getValueProps={(v) => ({ value: (v as string) || undefined })}
+          >
+            <Select
+              allowClear
+              showSearch
+              placeholder={t('pages.nodes.outboundTagPlaceholder')}
+              options={outboundOptions}
+            />
+          </Form.Item>
+
           <Form.Item
             label={t('pages.nodes.inboundSyncMode')}
             name="inboundSyncMode"

+ 4 - 0
frontend/src/pages/xray/routing/RoutingTab.css

@@ -235,3 +235,7 @@
   text-align: center;
 }
 
+.rule-disabled {
+  opacity: 0.5;
+  filter: grayscale(1);
+}

+ 10 - 0
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -58,6 +58,7 @@ export default function RoutingTab({
     () =>
       rules.map((rule, idx) => {
         const r: RuleRow = { key: idx };
+        r.enabled = rule.enabled !== false;
         r.domain = arrJoin(rule.domain);
         r.ip = arrJoin(rule.ip);
         r.port = rule.port;
@@ -185,6 +186,13 @@ export default function RoutingTab({
       [list[idx + 1], list[idx]] = [list[idx], list[idx + 1]];
     });
   }
+  function toggleRule(idx: number, enabled: boolean) {
+    mutate((tt) => {
+      const list = tt.routing?.rules;
+      if (!list || !list[idx]) return;
+      list[idx].enabled = enabled;
+    });
+  }
 
   function onHandlePointerDown(idx: number, ev: React.PointerEvent) {
     if (ev.button != null && ev.button !== 0) return;
@@ -247,6 +255,7 @@ export default function RoutingTab({
     moveUp,
     moveDown,
     confirmDelete,
+    toggleRule,
   });
 
   const tableScrollX = desktopColumns.reduce((sum, c) => {
@@ -289,6 +298,7 @@ export default function RoutingTab({
                     moveUp={moveUp}
                     moveDown={moveDown}
                     confirmDelete={confirmDelete}
+                    toggleRule={toggleRule}
                   />
                 ) : (
                   <Table

+ 14 - 3
frontend/src/pages/xray/routing/RuleCardList.tsx

@@ -1,6 +1,6 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Dropdown, Tag, Tooltip } from 'antd';
+import { Button, Dropdown, Tag, Tooltip, Switch } from 'antd';
 import {
   MoreOutlined,
   EditOutlined,
@@ -13,7 +13,7 @@ import {
 } from '@ant-design/icons';
 
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
-import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, ruleCriteriaChips } from './helpers';
+import { buildRemarkByTag, chipPreview, inboundTagChipPreview, inboundTagsDisplayTitle, isApiRule, ruleCriteriaChips } from './helpers';
 import type { RuleRow } from './types';
 
 interface RuleCardListProps {
@@ -25,6 +25,7 @@ interface RuleCardListProps {
   moveUp: (idx: number) => void;
   moveDown: (idx: number) => void;
   confirmDelete: (idx: number) => void;
+  toggleRule: (idx: number, enabled: boolean) => void;
 }
 
 export default function RuleCardList({
@@ -36,6 +37,7 @@ export default function RuleCardList({
   moveUp,
   moveDown,
   confirmDelete,
+  toggleRule,
 }: RuleCardListProps) {
   const { t } = useTranslation();
   const { data: inboundOptions } = useInboundOptions();
@@ -50,7 +52,9 @@ export default function RuleCardList({
             key={rule.key}
             className={`rule-card ${draggedIndex === index ? 'row-dragging' : ''} ${
               dropTargetIndex === index && draggedIndex != null && index < draggedIndex ? 'drop-before' : ''
-            } ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''}`}
+            } ${dropTargetIndex === index && draggedIndex != null && index > draggedIndex ? 'drop-after' : ''} ${
+              rule.enabled === false ? 'rule-disabled' : ''
+            }`}
             data-row-key={index}
           >
             <div className="rule-card-head">
@@ -72,6 +76,13 @@ export default function RuleCardList({
               >
                 <Button shape="circle" size="small" icon={<MoreOutlined />} />
               </Dropdown>
+              <Switch
+                size="small"
+                checked={rule.enabled !== false}
+                onChange={(checked) => toggleRule(index, checked)}
+                disabled={isApiRule(rule)}
+                style={{ marginLeft: 8 }}
+              />
             </div>
 
             <div className="rule-flow">

+ 17 - 1
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -5,9 +5,10 @@ import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design
 import { InputAddon } from '@/components/ui';
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
-import { buildRemarkByTag, formatInboundTag } from './helpers';
+import { buildRemarkByTag, formatInboundTag, isApiRule } from './helpers';
 
 export interface RoutingRule {
+  enabled?: boolean;
   type?: string;
   domain?: string | string[];
   ip?: string | string[];
@@ -38,6 +39,7 @@ interface RuleFormModalProps {
 type FormState = RuleFormValues;
 
 const initialForm = (): FormState => ({
+  enabled: true,
   domain: '',
   ip: '',
   port: '',
@@ -81,6 +83,7 @@ export default function RuleFormModal({
     if (!open) return;
     if (rule) {
       setForm({
+        enabled: rule.enabled !== false,
         domain: Array.isArray(rule.domain) ? rule.domain.join(',') : rule.domain || '',
         ip: Array.isArray(rule.ip) ? rule.ip.join(',') : rule.ip || '',
         port: rule.port || '',
@@ -109,6 +112,7 @@ export default function RuleFormModal({
     const v = validated.data;
     const built: Record<string, unknown> = {
       type: 'field',
+      enabled: v.enabled,
       domain: csv(v.domain),
       ip: csv(v.ip),
       port: v.port,
@@ -151,6 +155,18 @@ export default function RuleFormModal({
       onCancel={onClose}
     >
       <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
+        <Form.Item label={t('enable')}>
+          <Select
+            value={form.enabled}
+            onChange={(v) => update('enabled', v)}
+            disabled={isApiRule(rule ?? {})}
+            options={[
+              { value: true, label: t('enable') },
+              { value: false, label: t('disable') },
+            ]}
+          />
+        </Form.Item>
+
         <Form.Item
           label={
             <Tooltip title={t('pages.xray.rules.useComma')}>

+ 7 - 0
frontend/src/pages/xray/routing/helpers.ts

@@ -69,6 +69,13 @@ export function inboundTagChipPreview(
   return chipPreviewParts(formatInboundTagList(tags, remarkByTag));
 }
 
+/** The internal api rule (stats traffic) — its enabled state must stay locked on. */
+export function isApiRule(rule: { outboundTag?: string; inboundTag?: string | string[] }): boolean {
+  if (rule.outboundTag !== 'api') return false;
+  const tags = Array.isArray(rule.inboundTag) ? rule.inboundTag : csv(rule.inboundTag);
+  return tags.includes('api');
+}
+
 export function ruleCriteriaChips(rule: RuleRow) {
   const chips: { label: string; value?: string }[] = [];
   if (rule.domain) chips.push({ label: 'Domain', value: rule.domain });

+ 1 - 0
frontend/src/pages/xray/routing/types.ts

@@ -1,5 +1,6 @@
 export interface RuleRow {
   key: number;
+  enabled?: boolean;
   domain?: string;
   ip?: string;
   port?: string;

+ 55 - 31
frontend/src/pages/xray/routing/useRoutingColumns.tsx

@@ -1,6 +1,6 @@
 import { useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Dropdown, Tag } from 'antd';
+import { Button, Dropdown, Switch, Tag } from 'antd';
 import {
   MoreOutlined,
   EditOutlined,
@@ -15,7 +15,7 @@ import type { ColumnsType } from 'antd/es/table';
 
 import { useInboundOptions } from '@/api/queries/useInboundOptions';
 import CriterionRow from './CriterionRow';
-import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle } from './helpers';
+import { buildRemarkByTag, formatInboundTagList, inboundTagsDisplayTitle, isApiRule } from './helpers';
 import type { RuleRow } from './types';
 
 interface RoutingColumnsParams {
@@ -28,6 +28,7 @@ interface RoutingColumnsParams {
   moveUp: (idx: number) => void;
   moveDown: (idx: number) => void;
   confirmDelete: (idx: number) => void;
+  toggleRule: (idx: number, enabled: boolean) => void;
 }
 
 export function useRoutingColumns({
@@ -40,6 +41,7 @@ export function useRoutingColumns({
   moveUp,
   moveDown,
   confirmDelete,
+  toggleRule,
 }: RoutingColumnsParams): ColumnsType<RuleRow> {
   const { t } = useTranslation();
   const { data: inboundOptions } = useInboundOptions();
@@ -49,44 +51,66 @@ export function useRoutingColumns({
       {
         title: '#',
         align: 'center',
-        width: 100,
-        key: 'action',
+        width: 60,
+        key: 'index',
         render: (_v, _r, index) => (
-          <div className="action-cell">
+          <div className="action-cell" style={{ justifyContent: 'center' }}>
             <HolderOutlined
               className="drag-handle"
               title={t('pages.xray.routing.dragToReorder')}
               onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
             />
             <span className="row-index">{index + 1}</span>
-            <div className={!isMobile ? 'action-buttons' : ''}>
-              {!isMobile && (
-                <Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
-              )}
-              <Dropdown
-                trigger={['click']}
-                menu={{
-                  items: [
-                    ...(isMobile
-                      ? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
-                      : []),
-                    { key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
-                    {
-                      key: 'down',
-                      label: <ArrowDownOutlined />,
-                      disabled: index === rowsLength - 1,
-                      onClick: () => moveDown(index),
-                    },
-                    { key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
-                  ],
-                }}
-              >
-                <Button shape="circle" size="small" icon={<MoreOutlined />} />
-              </Dropdown>
-            </div>
           </div>
         ),
       },
+      {
+        title: t('pages.clients.actions'),
+        align: 'center',
+        width: 80,
+        key: 'action',
+        render: (_v, _r, index) => (
+          <div className={!isMobile ? 'action-buttons' : ''} style={{ justifyContent: 'center', margin: 0 }}>
+            {!isMobile && (
+              <Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
+            )}
+            <Dropdown
+              trigger={['click']}
+              menu={{
+                items: [
+                  ...(isMobile
+                    ? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
+                    : []),
+                  { key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
+                  {
+                    key: 'down',
+                    label: <ArrowDownOutlined />,
+                    disabled: index === rowsLength - 1,
+                    onClick: () => moveDown(index),
+                  },
+                  { key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
+                ],
+              }}
+            >
+              <Button shape="circle" size="small" icon={<MoreOutlined />} />
+            </Dropdown>
+          </div>
+        ),
+      },
+      {
+        title: t('enable'),
+        align: 'center',
+        width: 80,
+        key: 'enabled',
+        render: (_v, _r, index) => (
+          <Switch
+            size="small"
+            checked={_r.enabled !== false}
+            onChange={(checked) => toggleRule(index, checked)}
+            disabled={isApiRule(_r)}
+          />
+        ),
+      },
       {
         title: t('pages.xray.rules.source'),
         align: 'left',
@@ -184,6 +208,6 @@ export function useRoutingColumns({
           ),
       },
     ],
-    [t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete],
+    [t, isMobile, rowsLength, showSource, showBalancer, remarkByTag, onHandlePointerDown, openEdit, moveUp, moveDown, confirmDelete, toggleRule],
   );
 }

+ 12 - 0
frontend/src/schemas/client.ts

@@ -71,9 +71,20 @@ export const ClientPageResponseSchema = z.object({
   groups: nullableStringArray.optional(),
 });
 
+// A per-client external link surfaced in the client's subscription:
+// kind=link is a single share link, kind=subscription is a remote sub URL.
+export const ExternalLinkSchema = z.object({
+  kind: z.enum(['link', 'subscription']).default('link'),
+  value: z.string(),
+  remark: z.string().optional().default(''),
+}).loose();
+
+export const ExternalLinkListSchema = z.array(ExternalLinkSchema).nullable().transform((v) => v ?? []);
+
 export const ClientHydrateSchema = z.object({
   client: ClientRecordSchema,
   inboundIds: nullableNumberArray,
+  externalLinks: ExternalLinkListSchema.optional(),
 });
 
 export const BulkAdjustResultSchema = z.object({
@@ -203,6 +214,7 @@ export const ClientBulkAddFormSchema = z.object({
 export type ClientRecord = z.infer<typeof ClientRecordSchema>;
 export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
 export type InboundOption = z.infer<typeof InboundOptionSchema>;
+export type ExternalLink = z.infer<typeof ExternalLinkSchema>;
 export type ClientsSummary = z.infer<typeof ClientsSummarySchema>;
 export type ClientPageResponse = z.infer<typeof ClientPageResponseSchema>;
 export type ClientHydrate = z.infer<typeof ClientHydrateSchema>;

+ 2 - 0
frontend/src/schemas/node.ts

@@ -34,6 +34,7 @@ export const NodeRecordSchema = z.object({
   inboundSyncMode: z.enum(['all', 'selected']).optional(),
   // Backend serializes a nil []string as null for nodes saved before #5178.
   inboundTags: z.array(z.string()).nullish(),
+  outboundTag: z.string().optional(),
   // Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
   // whether it's a read-only transitive sub-node surfaced from a downstream node.
   guid: z.string().optional(),
@@ -70,6 +71,7 @@ export const NodeFormSchema = z.object({
   // Unmounted when sync mode is "all" (absent from antd onFinish values) and
   // serialized as null by the backend for a nil slice — tolerate both.
   inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
+  outboundTag: z.string().optional(),
 });
 
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;

+ 41 - 18
frontend/src/schemas/protocols/security/reality.ts

@@ -14,28 +14,51 @@ export const RealityClientSettingsSchema = z.object({
 });
 export type RealityClientSettings = z.infer<typeof RealityClientSettingsSchema>;
 
+// xray-core accepts both `target` and `dest` as the REALITY destination —
+// they are aliases (infra/conf/transport_internet.go: REALITYConfig has
+// `json:"target"` and `json:"dest"`). The panel writes `target`, but configs
+// produced by older panel builds, external tools, or the panel's own
+// `/panel/api/inbounds` API commonly use `dest`. Map `dest` -> `target` on
+// parse when `target` is absent/empty: otherwise such an inbound loads with
+// an empty (required) Target field even though it runs fine, and re-saving
+// it serializes the blank `target` and drops the working `dest` — silently
+// breaking REALITY on the next xray restart.
+const aliasRealityDest = (value: unknown): unknown => {
+  if (value && typeof value === 'object' && !Array.isArray(value)) {
+    const obj = value as Record<string, unknown>;
+    const hasTarget = typeof obj.target === 'string' && obj.target !== '';
+    if (!hasTarget && typeof obj.dest === 'string' && obj.dest !== '') {
+      return { ...obj, target: obj.dest };
+    }
+  }
+  return value;
+};
+
 // Reality stream payload. `serverNames` and `shortIds` are stored as
 // comma-joined strings in the panel class but ship as string[] on the wire
 // — fixtures round-trip through the array form. `target` is the dest host
 // Reality piggybacks on; the panel auto-generates random target+SNI when
 // blank.
-export const RealityStreamSettingsSchema = z.object({
-  show: z.boolean().default(false),
-  xver: z.number().int().min(0).default(0),
-  target: z.string().default(''),
-  serverNames: z.array(z.string()).default([]),
-  privateKey: z.string().default(''),
-  minClientVer: z.string().default(''),
-  maxClientVer: z.string().default(''),
-  maxTimediff: z.number().int().min(0).default(0),
-  shortIds: z.array(z.string()).default([]),
-  mldsa65Seed: z.string().default(''),
-  settings: RealityClientSettingsSchema.default({
-    publicKey: '',
-    fingerprint: 'chrome',
-    serverName: '',
-    spiderX: '/',
-    mldsa65Verify: '',
+export const RealityStreamSettingsSchema = z.preprocess(
+  aliasRealityDest,
+  z.object({
+    show: z.boolean().default(false),
+    xver: z.number().int().min(0).default(0),
+    target: z.string().default(''),
+    serverNames: z.array(z.string()).default([]),
+    privateKey: z.string().default(''),
+    minClientVer: z.string().default(''),
+    maxClientVer: z.string().default(''),
+    maxTimediff: z.number().int().min(0).default(0),
+    shortIds: z.array(z.string()).default([]),
+    mldsa65Seed: z.string().default(''),
+    settings: RealityClientSettingsSchema.default({
+      publicKey: '',
+      fingerprint: 'chrome',
+      serverName: '',
+      spiderX: '/',
+      mldsa65Verify: '',
+    }),
   }),
-});
+);
 export type RealityStreamSettings = z.infer<typeof RealityStreamSettingsSchema>;

+ 1 - 0
frontend/src/schemas/routing.ts

@@ -17,6 +17,7 @@ export type RuleWebhook = z.infer<typeof RuleWebhookSchema>;
 
 export const RuleObjectSchema = z.object({
   type: z.literal('field').default('field'),
+  enabled: z.boolean().optional(),
   domain: z.array(z.string()).optional(),
   ip: z.array(z.string()).optional(),
   port: PortValueSchema.optional(),

+ 1 - 0
frontend/src/schemas/xray.ts

@@ -84,6 +84,7 @@ export const OutboundTestResultSchema = z.object({
 export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
 
 export const RuleFormSchema = z.object({
+  enabled: z.boolean(),
   domain: z.string(),
   ip: z.string(),
   port: z.string(),

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

@@ -61,6 +61,46 @@ exports[`FinalMaskStreamSettingsSchema fixtures > parses quic-params byte-stably
 }
 `;
 
+exports[`FinalMaskStreamSettingsSchema fixtures > parses realm-tls byte-stably 1`] = `
+{
+  "tcp": [],
+  "udp": [
+    {
+      "settings": {
+        "stunServers": [
+          "stun.l.google.com:19302",
+        ],
+        "tlsConfig": {
+          "allowInsecure": false,
+          "alpn": [
+            "h3",
+          ],
+          "fingerprint": "chrome",
+          "serverName": "example.com",
+        },
+        "url": "realm://[email protected]/my-realm",
+      },
+      "type": "realm",
+    },
+  ],
+}
+`;
+
+exports[`FinalMaskStreamSettingsSchema fixtures > parses salamander-gecko byte-stably 1`] = `
+{
+  "tcp": [],
+  "udp": [
+    {
+      "settings": {
+        "packetSize": "100-200",
+        "password": "swordfish",
+      },
+      "type": "salamander",
+    },
+  ],
+}
+`;
+
 exports[`FinalMaskStreamSettingsSchema fixtures > parses tcp-mask byte-stably 1`] = `
 {
   "tcp": [

+ 20 - 0
frontend/src/test/finalmask.test.ts

@@ -1,6 +1,7 @@
 /// <reference types="vite/client" />
 import { describe, expect, it } from 'vitest';
 
+import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
 import { FinalMaskStreamSettingsSchema } from '@/schemas/protocols/stream';
 
 const fixtures = import.meta.glob<unknown>(
@@ -24,3 +25,22 @@ describe('FinalMaskStreamSettingsSchema fixtures', () => {
     });
   }
 });
+
+describe('parseGeckoPacketSize', () => {
+  it('accepts positive ordered packet size ranges', () => {
+    expect(parseGeckoPacketSize('512-1200')).toEqual({ min: 512, max: 1200 });
+    expect(parseGeckoPacketSize('1200-1200')).toEqual({ min: 1200, max: 1200 });
+    expect(parseGeckoPacketSize('1-2048')).toEqual({ min: 1, max: 2048 });
+  });
+
+  it('rejects invalid packet size ranges', () => {
+    expect(parseGeckoPacketSize('')).toBeNull();
+    expect(parseGeckoPacketSize('0-1200')).toBeNull();
+    expect(parseGeckoPacketSize('1200-512')).toBeNull();
+    expect(parseGeckoPacketSize('512')).toBeNull();
+    expect(parseGeckoPacketSize('512-abc')).toBeNull();
+    // exceeds xray-core's gecko buffer (max 2048)
+    expect(parseGeckoPacketSize('512-2049')).toBeNull();
+    expect(parseGeckoPacketSize('512-9999')).toBeNull();
+  });
+});

+ 17 - 0
frontend/src/test/golden/fixtures/finalmask/realm-tls.json

@@ -0,0 +1,17 @@
+{
+  "udp": [
+    {
+      "type": "realm",
+      "settings": {
+        "url": "realm://[email protected]/my-realm",
+        "stunServers": ["stun.l.google.com:19302"],
+        "tlsConfig": {
+          "serverName": "example.com",
+          "allowInsecure": false,
+          "alpn": ["h3"],
+          "fingerprint": "chrome"
+        }
+      }
+    }
+  ]
+}

+ 5 - 0
frontend/src/test/golden/fixtures/finalmask/salamander-gecko.json

@@ -0,0 +1,5 @@
+{
+  "udp": [
+    { "type": "salamander", "settings": { "password": "swordfish", "packetSize": "100-200" } }
+  ]
+}

+ 85 - 0
frontend/src/test/inbound-link.test.ts

@@ -437,6 +437,91 @@ describe('genShadowsocksLink', () => {
   }
 });
 
+describe('IPv6 bracket wrapping in share-link authority', () => {
+  it('genVlessLink brackets a bare IPv6 address', () => {
+    const [, raw] = fixturesForProtocol('vless')[0];
+    const typed = InboundSchema.parse(raw);
+    const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
+
+    const link = genVlessLink({
+      inbound: typed,
+      address: '2001:db8::1',
+      port: 443,
+      clientId,
+    });
+    expect(new URL(link).host).toBe('[2001:db8::1]:443');
+  });
+
+  it('genTrojanLink brackets a bare IPv6 address', () => {
+    const [, raw] = fixturesForProtocol('trojan')[0];
+    const typed = InboundSchema.parse(raw);
+    const clientPassword = (raw as { settings: { clients: Array<{ password: string }> } }).settings.clients[0].password;
+
+    const link = genTrojanLink({
+      inbound: typed,
+      address: '2001:db8::1',
+      port: 443,
+      clientPassword,
+    });
+    expect(new URL(link).host).toBe('[2001:db8::1]:443');
+  });
+
+  it('genShadowsocksLink brackets a bare IPv6 address', () => {
+    const [, raw] = fixturesForProtocol('shadowsocks')[0];
+    const typed = InboundSchema.parse(raw);
+    const clientPassword = (raw as { settings: { clients?: Array<{ password: string }> } }).settings.clients?.[0]?.password ?? '';
+
+    const link = genShadowsocksLink({
+      inbound: typed,
+      address: '2001:db8::1',
+      port: 443,
+      clientPassword,
+    });
+    expect(new URL(link).host).toBe('[2001:db8::1]:443');
+  });
+
+  it('genHysteriaLink brackets a bare IPv6 address', () => {
+    const [, raw] = fixturesForProtocol('hysteria')[0];
+    const typed = InboundSchema.parse(raw);
+    const clientAuth = (raw as { settings: { clients: Array<{ auth: string }> } }).settings.clients[0].auth;
+
+    const link = genHysteriaLink({
+      inbound: typed,
+      address: '2001:db8::1',
+      port: 443,
+      clientAuth,
+    });
+    expect(new URL(link).host).toBe('[2001:db8::1]:443');
+  });
+
+  it('genWireguardLink brackets a bare IPv6 address', () => {
+    const [, raw] = fixturesForProtocol('wireguard')[0];
+    const typed = InboundSchema.parse(raw);
+    if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture');
+    const settings = typed.settings as WireguardInboundSettings;
+
+    const link = genWireguardLink({
+      settings,
+      address: '2001:db8::1',
+      port: 443,
+      peerIndex: 0,
+    });
+    expect(new URL(link).host).toBe('[2001:db8::1]:443');
+  });
+
+  it('does not bracket IPv4 addresses or hostnames', () => {
+    const [, raw] = fixturesForProtocol('vless')[0];
+    const typed = InboundSchema.parse(raw);
+    const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
+
+    const v4 = genVlessLink({ inbound: typed, address: '203.0.113.7', port: 443, clientId });
+    expect(new URL(v4).host).toBe('203.0.113.7:443');
+
+    const host = genVlessLink({ inbound: typed, address: 'example.test', port: 443, clientId });
+    expect(new URL(host).host).toBe('example.test:443');
+  });
+});
+
 describe('external proxy pinned cert (pcs)', () => {
   const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
   const typed = InboundSchema.parse(raw);

+ 40 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -288,6 +288,46 @@ describe('parseHysteria2Link', () => {
     expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
   });
 
+  it('round-trips the salamander packetSize (Gecko) under fm', () => {
+    const fm = encodeURIComponent(JSON.stringify({
+      udp: [{ type: 'salamander', settings: { password: 'ftwfgb9655hh2mgo', packetSize: '100-200' } }],
+    }));
+    const link = `hysteria2://[email protected]:8443?security=tls&sni=news.domain.org&fm=${fm}#hy2-gecko`;
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    const settings = udp[0].settings as Record<string, unknown>;
+    expect(udp[0].type).toBe('salamander');
+    expect(settings.password).toBe('ftwfgb9655hh2mgo');
+    expect(settings.packetSize).toBe('100-200');
+  });
+
+  it('round-trips the realm tlsConfig under fm', () => {
+    const fm = encodeURIComponent(JSON.stringify({
+      udp: [{
+        type: 'realm',
+        settings: {
+          url: 'realm://[email protected]/my-realm',
+          stunServers: ['stun.l.google.com:19302'],
+          tlsConfig: { serverName: 'example.com', alpn: ['h3'], fingerprint: 'chrome', allowInsecure: false },
+        },
+      }],
+    }));
+    const link = `hysteria2://auth@srv:443?security=tls&sni=srv&fm=${fm}#hy2-realm`;
+    const out = parseHysteria2Link(link);
+    expect(out).not.toBeNull();
+    const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
+    const udp = finalmask.udp as Array<Record<string, unknown>>;
+    const settings = udp[0].settings as Record<string, unknown>;
+    expect(udp[0].type).toBe('realm');
+    expect(settings.url).toBe('realm://[email protected]/my-realm');
+    const tlsConfig = settings.tlsConfig as Record<string, unknown>;
+    expect(tlsConfig.serverName).toBe('example.com');
+    expect(tlsConfig.alpn).toEqual(['h3']);
+    expect(tlsConfig.fingerprint).toBe('chrome');
+  });
+
   it('defaults alpn to h3 when the link omits it', () => {
     const out = parseHysteria2Link('hysteria2://auth@srv:443?sni=example.com');
     const tls = (out!.streamSettings as Record<string, unknown>).tlsSettings as Record<string, unknown>;

+ 36 - 0
frontend/src/test/security.test.ts

@@ -2,6 +2,7 @@
 import { describe, expect, it } from 'vitest';
 
 import { SecuritySettingsSchema } from '@/schemas/protocols';
+import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
 
 const securityFixtures = import.meta.glob<unknown>(
   './golden/fixtures/security/*.json',
@@ -24,3 +25,38 @@ describe('SecuritySettingsSchema fixtures', () => {
     });
   }
 });
+
+describe('RealityStreamSettingsSchema dest -> target alias', () => {
+  it('maps legacy `dest` to `target` when `target` is absent', () => {
+    const parsed = RealityStreamSettingsSchema.parse({
+      dest: 'example.com:443',
+      serverNames: ['example.com'],
+    });
+    expect(parsed.target).toBe('example.com:443');
+  });
+
+  it('keeps `target` when both keys are present', () => {
+    const parsed = RealityStreamSettingsSchema.parse({
+      target: 'example.com:443',
+      dest: 'other.com:443',
+    });
+    expect(parsed.target).toBe('example.com:443');
+  });
+
+  it('does not let an empty `target` shadow a present `dest`', () => {
+    const parsed = RealityStreamSettingsSchema.parse({
+      target: '',
+      dest: 'example.com:443',
+    });
+    expect(parsed.target).toBe('example.com:443');
+  });
+
+  it('migrates `dest` through the security discriminated union', () => {
+    const parsed = SecuritySettingsSchema.parse({
+      security: 'reality',
+      realitySettings: { dest: 'caddy:443', serverNames: ['volov.online'] },
+    });
+    if (parsed.security !== 'reality') throw new Error('expected reality branch');
+    expect(parsed.realitySettings.target).toBe('caddy:443');
+  });
+});

+ 54 - 0
frontend/src/test/size-formatter.test.ts

@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { SizeFormatter } from '@/utils';
+
+describe('SizeFormatter.sizeFormat', () => {
+  it('formats zero and negative values', () => {
+    expect(SizeFormatter.sizeFormat(0)).toBe('0 B');
+    expect(SizeFormatter.sizeFormat(-1)).toBe('0 B');
+    expect(SizeFormatter.sizeFormat(null)).toBe('0 B');
+    expect(SizeFormatter.sizeFormat(undefined)).toBe('0 B');
+  });
+
+  it('formats bytes', () => {
+    expect(SizeFormatter.sizeFormat(512)).toBe('512 B');
+  });
+
+  it('formats kilobytes', () => {
+    expect(SizeFormatter.sizeFormat(1536)).toBe('1.50 KB');
+  });
+});
+
+describe('SizeFormatter.speedFormat', () => {
+  it('formats zero and negative values', () => {
+    expect(SizeFormatter.speedFormat(0)).toBe('0 B/s');
+    expect(SizeFormatter.speedFormat(-1)).toBe('0 B/s');
+    expect(SizeFormatter.speedFormat(null)).toBe('0 B/s');
+    expect(SizeFormatter.speedFormat(undefined)).toBe('0 B/s');
+  });
+
+  it('formats non-finite values as zero', () => {
+    expect(SizeFormatter.speedFormat(NaN)).toBe('0 B/s');
+    expect(SizeFormatter.speedFormat(Infinity)).toBe('0 B/s');
+    expect(SizeFormatter.sizeFormat(NaN)).toBe('0 B');
+    expect(SizeFormatter.sizeFormat(Infinity)).toBe('0 B');
+  });
+
+  it('formats bytes per second', () => {
+    expect(SizeFormatter.speedFormat(512)).toBe('512 B/s');
+    expect(SizeFormatter.speedFormat(1023)).toBe('1023 B/s');
+  });
+
+  it('formats kilobytes per second', () => {
+    expect(SizeFormatter.speedFormat(1024)).toBe('1.00 KB/s');
+    expect(SizeFormatter.speedFormat(1536)).toBe('1.50 KB/s');
+  });
+
+  it('formats megabytes per second', () => {
+    expect(SizeFormatter.speedFormat(1024 * 1024)).toBe('1.00 MB/s');
+    expect(SizeFormatter.speedFormat(2.5 * 1024 * 1024)).toBe('2.50 MB/s');
+  });
+
+  it('formats gigabytes per second', () => {
+    expect(SizeFormatter.speedFormat(1024 * 1024 * 1024)).toBe('1.00 GB/s');
+  });
+});

+ 6 - 1
frontend/src/utils/index.ts

@@ -646,7 +646,7 @@ export class SizeFormatter {
   static readonly ONE_PB = SizeFormatter.ONE_TB * 1024;
 
   static sizeFormat(size: number | null | undefined): string {
-    if (size == null || size <= 0) return '0 B';
+    if (size == null || !Number.isFinite(size) || size <= 0) return '0 B';
     if (size < SizeFormatter.ONE_KB) return size.toFixed(0) + ' B';
     if (size < SizeFormatter.ONE_MB) return (size / SizeFormatter.ONE_KB).toFixed(2) + ' KB';
     if (size < SizeFormatter.ONE_GB) return (size / SizeFormatter.ONE_MB).toFixed(2) + ' MB';
@@ -654,6 +654,11 @@ export class SizeFormatter {
     if (size < SizeFormatter.ONE_PB) return (size / SizeFormatter.ONE_TB).toFixed(2) + ' TB';
     return (size / SizeFormatter.ONE_PB).toFixed(2) + ' PB';
   }
+
+  // Same unit ladder as sizeFormat, expressed per-second.
+  static speedFormat(bps: number | null | undefined): string {
+    return SizeFormatter.sizeFormat(bps) + '/s';
+  }
 }
 
 export class CPUFormatter {

+ 4 - 4
install.sh

@@ -1305,17 +1305,17 @@ install_x-ui() {
 
     # Download resources
     if [ $# == 0 ]; then
-        tag_version=$(curl -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
+        tag_version=$(curl -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
         if [[ ! -n "$tag_version" ]]; then
             echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
-            tag_version=$(curl -4 -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
+            tag_version=$(curl -4 -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
             if [[ ! -n "$tag_version" ]]; then
                 echo -e "${red}Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later${plain}"
                 exit 1
             fi
         fi
         echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
-        curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
+        curl -4fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
         if [[ $? -ne 0 ]]; then
             echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}"
             exit 1
@@ -1332,7 +1332,7 @@ install_x-ui() {
 
         url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
         echo -e "Beginning to install x-ui $1"
-        curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz ${url}
+        curl -4fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz ${url}
         if [[ $? -ne 0 ]]; then
             echo -e "${red}Download x-ui $1 failed, please check if the version exists ${plain}"
             exit 1

+ 18 - 0
internal/config/config.go

@@ -9,6 +9,7 @@ import (
 	"os"
 	"path/filepath"
 	"runtime"
+	"strconv"
 	"strings"
 	"testing"
 )
@@ -63,6 +64,23 @@ func IsSkipHSTS() bool {
 	return os.Getenv("XUI_SKIP_HSTS") == "true"
 }
 
+func GetPortOverride() (port int, configured bool, err error) {
+	value, ok := os.LookupEnv("XUI_PORT")
+	if !ok || strings.TrimSpace(value) == "" {
+		return 0, false, nil
+	}
+
+	port, err = strconv.Atoi(strings.TrimSpace(value))
+	if err != nil {
+		return 0, true, fmt.Errorf("parse XUI_PORT: %w", err)
+	}
+	if port < 1 || port > 65535 {
+		return 0, true, fmt.Errorf("XUI_PORT must be between 1 and 65535")
+	}
+
+	return port, true, nil
+}
+
 // GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
 func GetBinFolderPath() string {
 	binFolderPath := os.Getenv("XUI_BIN_FOLDER")

+ 61 - 0
internal/config/config_test.go

@@ -0,0 +1,61 @@
+package config
+
+import (
+	"os"
+	"testing"
+)
+
+func TestGetPortOverride(t *testing.T) {
+	tests := []struct {
+		name       string
+		value      string
+		set        bool
+		wantPort   int
+		configured bool
+		wantErr    bool
+	}{
+		{name: "unset"},
+		{name: "empty", value: "", set: true},
+		{name: "whitespace", value: "   ", set: true},
+		{name: "minimum", value: "1", set: true, wantPort: 1, configured: true},
+		{name: "default panel port", value: "2053", set: true, wantPort: 2053, configured: true},
+		{name: "surrounding whitespace", value: " 8080 ", set: true, wantPort: 8080, configured: true},
+		{name: "maximum", value: "65535", set: true, wantPort: 65535, configured: true},
+		{name: "zero", value: "0", set: true, configured: true, wantErr: true},
+		{name: "above maximum", value: "65536", set: true, configured: true, wantErr: true},
+		{name: "negative", value: "-1", set: true, configured: true, wantErr: true},
+		{name: "non-numeric", value: "abc", set: true, configured: true, wantErr: true},
+		{name: "decimal", value: "8080.0", set: true, configured: true, wantErr: true},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.set {
+				t.Setenv("XUI_PORT", tt.value)
+			} else {
+				original, existed := os.LookupEnv("XUI_PORT")
+				if err := os.Unsetenv("XUI_PORT"); err != nil {
+					t.Fatalf("unset XUI_PORT: %v", err)
+				}
+				t.Cleanup(func() {
+					if existed {
+						_ = os.Setenv("XUI_PORT", original)
+					} else {
+						_ = os.Unsetenv("XUI_PORT")
+					}
+				})
+			}
+
+			port, configured, err := GetPortOverride()
+			if port != tt.wantPort {
+				t.Errorf("port = %d, want %d", port, tt.wantPort)
+			}
+			if configured != tt.configured {
+				t.Errorf("configured = %t, want %t", configured, tt.configured)
+			}
+			if (err != nil) != tt.wantErr {
+				t.Errorf("error = %v, wantErr %t", err, tt.wantErr)
+			}
+		})
+	}
+}

+ 1 - 0
internal/database/db.go

@@ -69,6 +69,7 @@ func initModels() error {
 		&model.ApiToken{},
 		&model.ClientRecord{},
 		&model.ClientInbound{},
+		&model.ClientExternalLink{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},

+ 40 - 0
internal/database/index_tags_test.go

@@ -0,0 +1,40 @@
+package database
+
+import (
+	"testing"
+
+	"gorm.io/driver/sqlite"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// AutoMigrate must create the hot-path indexes added for client group filters
+// and client_traffics inbound lookups. gorm creates missing indexes on migrate,
+// so this also protects existing DBs after upgrade.
+func TestAutoMigrateCreatesHotPathIndexes(t *testing.T) {
+	db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
+		Logger: logger.Default.LogMode(logger.Silent),
+	})
+	if err != nil {
+		t.Fatalf("open sqlite: %v", err)
+	}
+	if err := db.AutoMigrate(&model.ClientRecord{}, &xray.ClientTraffic{}); err != nil {
+		t.Fatalf("automigrate: %v", err)
+	}
+
+	cases := []struct {
+		model any
+		index string
+	}{
+		{&model.ClientRecord{}, "idx_client_record_group"},
+		{&xray.ClientTraffic{}, "idx_client_traffics_inbound"},
+	}
+	for _, c := range cases {
+		if !db.Migrator().HasIndex(c.model, c.index) {
+			t.Errorf("expected index %q to exist after AutoMigrate", c.index)
+		}
+	}
+}

+ 1 - 0
internal/database/migrate_data.go

@@ -47,6 +47,7 @@ func migrationModels() []any {
 		&model.InboundClientIps{},
 		&model.ClientRecord{},
 		&model.ClientInbound{},
+		&model.ClientExternalLink{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},
 		&model.OutboundSubscription{},

+ 27 - 1
internal/database/model/model.go

@@ -462,6 +462,7 @@ type Node struct {
 	PinnedCertSha256    string   `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
 	InboundSyncMode     string   `json:"inboundSyncMode" form:"inboundSyncMode" gorm:"column:inbound_sync_mode;default:all" validate:"omitempty,oneof=all selected"`
 	InboundTags         []string `json:"inboundTags" form:"inboundTags" gorm:"serializer:json;column:inbound_tags"`
+	OutboundTag         string   `json:"outboundTag" form:"outboundTag" gorm:"column:outbound_tag"`
 
 	// Guid is the remote panel's stable self-identifier (its panelGuid),
 	// learned from each heartbeat. It is the globally stable node identity used
@@ -571,7 +572,7 @@ type ClientRecord struct {
 	ExpiryTime int64  `json:"expiryTime" gorm:"column:expiry_time"`
 	Enable     bool   `json:"enable" gorm:"default:true"`
 	TgID       int64  `json:"tgId" gorm:"column:tg_id"`
-	Group      string `json:"group" gorm:"column:group_name;default:''"`
+	Group      string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"`
 	Comment    string `json:"comment"`
 	Reset      int    `json:"reset" gorm:"default:0"`
 	CreatedAt  int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
@@ -629,6 +630,31 @@ type ClientInbound struct {
 
 func (ClientInbound) TableName() string { return "client_inbounds" }
 
+// ClientExternalLink is a per-client entry surfaced in the client's
+// subscription. Two kinds:
+//   - "link": a single third-party share link (vless://, vmess://, trojan://,
+//     ss://, hysteria2://, wireguard://). Emitted verbatim in raw subs; parsed
+//     into an outbound/proxy for JSON and Clash.
+//   - "subscription": a remote subscription URL. The panel fetches it (cached),
+//     decodes its links, and merges them into the client's subscription.
+type ClientExternalLink struct {
+	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	ClientId  int    `json:"clientId" gorm:"index;column:client_id"`
+	Kind      string `json:"kind" gorm:"column:kind"`
+	Value     string `json:"value" gorm:"column:value"`
+	Remark    string `json:"remark" gorm:"column:remark"`
+	SortIndex int    `json:"sortIndex" gorm:"column:sort_index"`
+	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+}
+
+func (ClientExternalLink) TableName() string { return "client_external_links" }
+
+// External link kinds.
+const (
+	ExternalLinkKindLink         = "link"
+	ExternalLinkKindSubscription = "subscription"
+)
+
 type InboundFallback struct {
 	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	MasterId  int    `json:"masterId" gorm:"index;not null;column:master_id"`

+ 238 - 0
internal/sub/clash_external.go

@@ -0,0 +1,238 @@
+package sub
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+)
+
+// clashProxyFromExternal parses a pasted share link and converts it into a
+// mihomo/Clash proxy entry named `name`. Returns nil for links Clash can't
+// represent (the entry is then skipped, mirroring how getProxies drops
+// unsupported inbound protocols). vmess/vless/trojan reuse the existing
+// applyTransport/applySecurity helpers; ss/hysteria2/wireguard map directly.
+func (s *SubClashService) clashProxyFromExternal(rawLink, name string) map[string]any {
+	ob := parseExternalLink(rawLink)
+	if ob == nil {
+		return nil
+	}
+	protocol, _ := ob["protocol"].(string)
+	settings, _ := ob["settings"].(map[string]any)
+	stream, _ := ob["streamSettings"].(map[string]any)
+	if stream == nil {
+		stream = map[string]any{}
+	}
+	if settings == nil {
+		return nil
+	}
+
+	proxy := map[string]any{"name": name, "udp": true}
+
+	switch protocol {
+	case "vmess":
+		vnext, _ := settings["vnext"].([]any)
+		if len(vnext) == 0 {
+			return nil
+		}
+		vn, _ := vnext[0].(map[string]any)
+		users, _ := vn["users"].([]any)
+		if vn == nil || len(users) == 0 {
+			return nil
+		}
+		user, _ := users[0].(map[string]any)
+		proxy["type"] = "vmess"
+		proxy["server"] = fmt.Sprint(vn["address"])
+		proxy["port"] = clashInt(vn["port"])
+		proxy["uuid"] = fmt.Sprint(user["id"])
+		proxy["alterId"] = 0
+		cipher, _ := user["security"].(string)
+		if cipher == "" {
+			cipher = "auto"
+		}
+		proxy["cipher"] = cipher
+	case "vless":
+		proxy["type"] = "vless"
+		proxy["server"] = fmt.Sprint(settings["address"])
+		proxy["port"] = clashInt(settings["port"])
+		proxy["uuid"] = fmt.Sprint(settings["id"])
+		if flow, _ := settings["flow"].(string); flow != "" {
+			proxy["flow"] = flow
+		}
+	case "trojan":
+		server := firstServer(settings)
+		if server == nil {
+			return nil
+		}
+		proxy["type"] = "trojan"
+		proxy["server"] = fmt.Sprint(server["address"])
+		proxy["port"] = clashInt(server["port"])
+		proxy["password"] = fmt.Sprint(server["password"])
+	case "shadowsocks":
+		server := firstServer(settings)
+		if server == nil {
+			server = settings
+		}
+		method, _ := server["method"].(string)
+		if method == "" {
+			return nil
+		}
+		proxy["type"] = "ss"
+		proxy["server"] = fmt.Sprint(server["address"])
+		proxy["port"] = clashInt(server["port"])
+		proxy["cipher"] = method
+		proxy["password"] = fmt.Sprint(server["password"])
+		return proxy
+	case "hysteria":
+		return clashHysteriaFromExternal(settings, stream, name)
+	case "wireguard":
+		return clashWireguardFromExternal(settings, name)
+	default:
+		return nil
+	}
+
+	network, _ := stream["network"].(string)
+	if !s.applyTransport(proxy, network, stream) {
+		return nil
+	}
+	security, _ := stream["security"].(string)
+	if !s.applySecurity(proxy, security, stream) {
+		return nil
+	}
+	return proxy
+}
+
+func firstServer(settings map[string]any) map[string]any {
+	servers, _ := settings["servers"].([]any)
+	if len(servers) == 0 {
+		return nil
+	}
+	server, _ := servers[0].(map[string]any)
+	return server
+}
+
+func clashHysteriaFromExternal(settings, stream map[string]any, name string) map[string]any {
+	hy, _ := stream["hysteriaSettings"].(map[string]any)
+	auth := ""
+	if hy != nil {
+		auth, _ = hy["auth"].(string)
+	}
+	if auth == "" {
+		return nil
+	}
+	proxy := map[string]any{
+		"name":     name,
+		"type":     "hysteria2",
+		"server":   fmt.Sprint(settings["address"]),
+		"port":     clashInt(settings["port"]),
+		"password": auth,
+		"udp":      true,
+	}
+	if tls, _ := stream["tlsSettings"].(map[string]any); tls != nil {
+		if sni, _ := tls["serverName"].(string); sni != "" {
+			proxy["sni"] = sni
+		}
+		if alpn := clashStringList(tls["alpn"]); len(alpn) > 0 {
+			proxy["alpn"] = alpn
+		}
+		if fp, _ := tls["fingerprint"].(string); fp != "" {
+			proxy["client-fingerprint"] = fp
+		}
+	}
+	return proxy
+}
+
+func clashWireguardFromExternal(settings map[string]any, name string) map[string]any {
+	peers, _ := settings["peers"].([]any)
+	if len(peers) == 0 {
+		return nil
+	}
+	peer, _ := peers[0].(map[string]any)
+	if peer == nil {
+		return nil
+	}
+	host, port := splitClashHostPort(fmt.Sprint(peer["endpoint"]))
+	if host == "" || port == 0 {
+		return nil
+	}
+	proxy := map[string]any{
+		"name":   name,
+		"type":   "wireguard",
+		"server": host,
+		"port":   port,
+		"udp":    true,
+	}
+	if sk, _ := settings["secretKey"].(string); sk != "" {
+		proxy["private-key"] = sk
+	}
+	if pk, _ := peer["publicKey"].(string); pk != "" {
+		proxy["public-key"] = pk
+	}
+	if psk, _ := peer["preSharedKey"].(string); psk != "" {
+		proxy["pre-shared-key"] = psk
+	}
+	for _, addr := range clashStringList(settings["address"]) {
+		ip := stripCIDR(addr)
+		if strings.Contains(ip, ":") {
+			proxy["ipv6"] = ip
+		} else {
+			proxy["ip"] = ip
+		}
+	}
+	return proxy
+}
+
+func clashInt(v any) int {
+	switch x := v.(type) {
+	case int:
+		return x
+	case int64:
+		return int(x)
+	case float64:
+		return int(x)
+	case string:
+		n, _ := strconv.Atoi(x)
+		return n
+	default:
+		return 0
+	}
+}
+
+func clashStringList(v any) []string {
+	switch x := v.(type) {
+	case []any:
+		out := make([]string, 0, len(x))
+		for _, item := range x {
+			if s, ok := item.(string); ok && s != "" {
+				out = append(out, s)
+			}
+		}
+		return out
+	case []string:
+		return x
+	case string:
+		if x == "" {
+			return nil
+		}
+		return strings.Split(x, ",")
+	default:
+		return nil
+	}
+}
+
+func stripCIDR(addr string) string {
+	if i := strings.IndexByte(addr, '/'); i >= 0 {
+		return addr[:i]
+	}
+	return addr
+}
+
+func splitClashHostPort(endpoint string) (string, int) {
+	endpoint = strings.TrimSpace(endpoint)
+	i := strings.LastIndex(endpoint, ":")
+	if i < 0 {
+		return endpoint, 0
+	}
+	host := strings.Trim(endpoint[:i], "[]")
+	port, _ := strconv.Atoi(endpoint[i+1:])
+	return host, port
+}

+ 20 - 1
internal/sub/clash_service.go

@@ -25,9 +25,16 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 	// Set per-request state so resolveInboundAddress sees the node map.
 	s.SubService.PrepareForRequest(host)
 	inbounds, err := s.SubService.getInboundsBySubId(subId)
-	if err != nil || len(inbounds) == 0 {
+	if err != nil {
 		return "", "", err
 	}
+	externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
+	if err != nil {
+		return "", "", err
+	}
+	if len(inbounds) == 0 && len(externalLinks) == 0 {
+		return "", "", nil
+	}
 
 	var proxies []map[string]any
 
@@ -43,6 +50,18 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
 			proxies = append(proxies, s.getProxies(inbound, client, host)...)
 		}
 	}
+	for _, ext := range externalLinks {
+		for _, el := range expandEntry(ext) {
+			name := el.Name
+			if name == "" {
+				name = ext.Email
+			}
+			if proxy := s.clashProxyFromExternal(el.Link, name); proxy != nil {
+				seenEmails[ext.Email] = struct{}{}
+				proxies = append(proxies, proxy)
+			}
+		}
+	}
 
 	if len(proxies) == 0 {
 		return "", "", nil

+ 160 - 0
internal/sub/external_config.go

@@ -0,0 +1,160 @@
+package sub
+
+import (
+	"encoding/base64"
+	"net/url"
+	"strings"
+
+	"github.com/goccy/go-json"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+)
+
+// externalLinkEntry is one client × external-link row, resolved for a
+// subscription request. Email/Enable come from the owning client.
+type externalLinkEntry struct {
+	Kind   string
+	Value  string
+	Remark string
+	Email  string
+	Enable bool
+}
+
+// expandedLink is a single share link contributed by an entry, with the display
+// name to use (empty → keep the link's own remark / fall back to the email).
+type expandedLink struct {
+	Link string
+	Name string
+}
+
+// getClientExternalLinksBySubId returns every external-link row attached to a
+// client that carries the given subId, in stable order. Stays inside
+// internal/sub + database + util/link — no dependency on the panel service layer.
+func (s *SubService) getClientExternalLinksBySubId(subId string) ([]externalLinkEntry, error) {
+	db := database.GetDB()
+	var recs []model.ClientRecord
+	if err := db.Where("sub_id = ?", subId).Find(&recs).Error; err != nil {
+		return nil, err
+	}
+	if len(recs) == 0 {
+		return nil, nil
+	}
+	clientIds := make([]int, 0, len(recs))
+	byId := make(map[int]model.ClientRecord, len(recs))
+	for _, rec := range recs {
+		clientIds = append(clientIds, rec.Id)
+		byId[rec.Id] = rec
+	}
+
+	var rows []model.ClientExternalLink
+	if err := db.Where("client_id IN ?", clientIds).
+		Order("client_id ASC, sort_index ASC, id ASC").
+		Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	if len(rows) == 0 {
+		return nil, nil
+	}
+
+	out := make([]externalLinkEntry, 0, len(rows))
+	for _, r := range rows {
+		rec := byId[r.ClientId]
+		out = append(out, externalLinkEntry{
+			Kind:   r.Kind,
+			Value:  r.Value,
+			Remark: r.Remark,
+			Email:  rec.Email,
+			Enable: rec.Enable,
+		})
+	}
+	return out, nil
+}
+
+// expandEntry turns one entry into the concrete share links it contributes. A
+// "subscription" entry is fetched (cached) and its links are kept with their own
+// names; a "link" entry yields the single link with the row's remark.
+func expandEntry(e externalLinkEntry) []expandedLink {
+	if e.Kind == model.ExternalLinkKindSubscription {
+		links := fetchSubscriptionLinks(e.Value)
+		out := make([]expandedLink, 0, len(links))
+		for _, l := range links {
+			out = append(out, expandedLink{Link: l, Name: ""})
+		}
+		return out
+	}
+	return []expandedLink{{Link: e.Value, Name: e.Remark}}
+}
+
+// applyRemarkToLink rewrites a share link's display name to remark (when set),
+// leaving everything else byte-for-byte. vmess carries its remark in the base64
+// JSON `ps`; every other scheme carries it in the URL #fragment.
+func applyRemarkToLink(rawLink, remark string) string {
+	rawLink = strings.TrimSpace(rawLink)
+	if remark == "" {
+		return rawLink
+	}
+	if strings.HasPrefix(rawLink, "vmess://") {
+		return applyVmessRemark(rawLink, remark)
+	}
+	if i := strings.IndexByte(rawLink, '#'); i >= 0 {
+		rawLink = rawLink[:i]
+	}
+	return rawLink + "#" + url.PathEscape(remark)
+}
+
+func applyVmessRemark(rawLink, remark string) string {
+	b64 := strings.TrimPrefix(rawLink, "vmess://")
+	raw, err := base64.StdEncoding.DecodeString(padBase64Sub(b64))
+	if err != nil {
+		raw, err = base64.RawURLEncoding.DecodeString(strings.TrimRight(b64, "="))
+	}
+	if err != nil {
+		return rawLink
+	}
+	var j map[string]any
+	if err := json.Unmarshal(raw, &j); err != nil {
+		return rawLink
+	}
+	j["ps"] = remark
+	nb, err := json.Marshal(j)
+	if err != nil {
+		return rawLink
+	}
+	return "vmess://" + base64.StdEncoding.EncodeToString(nb)
+}
+
+func padBase64Sub(s string) string {
+	for len(s)%4 != 0 {
+		s += "="
+	}
+	return s
+}
+
+// parsedExternalOutbound turns a pasted share link into a structured Xray
+// outbound (tagged "proxy") for the JSON subscription. Returns nil when the
+// link can't be parsed — the caller skips it.
+func parsedExternalOutbound(rawLink string) json_util.RawMessage {
+	ob := parseExternalLink(rawLink)
+	if ob == nil {
+		return nil
+	}
+	ob["tag"] = "proxy"
+	b, err := json.MarshalIndent(ob, "", "  ")
+	if err != nil {
+		return nil
+	}
+	return b
+}
+
+// parseExternalLink parses a share link into the Xray outbound wire shape
+// (map), or nil if unsupported/invalid.
+func parseExternalLink(rawLink string) map[string]any {
+	res, err := link.ParseLink(strings.TrimSpace(rawLink))
+	if err != nil || res == nil || res.Outbound == nil {
+		return nil
+	}
+	return map[string]any(res.Outbound)
+}

+ 123 - 0
internal/sub/external_config_test.go

@@ -0,0 +1,123 @@
+package sub
+
+import (
+	"encoding/base64"
+	"net/url"
+	"strings"
+	"testing"
+
+	"github.com/goccy/go-json"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestApplyRemarkToLinkRewritesFragment(t *testing.T) {
+	link := "vless://[email protected]:443?security=reality&pbk=abc&sid=12#old-name"
+	out := applyRemarkToLink(link, "DE-Provider")
+	u, err := url.Parse(out)
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if u.Fragment != "DE-Provider" {
+		t.Fatalf("fragment = %q, want DE-Provider", u.Fragment)
+	}
+	// Everything before the fragment must be byte-for-byte preserved.
+	if !strings.HasPrefix(out, "vless://[email protected]:443?security=reality&pbk=abc&sid=12#") {
+		t.Fatalf("link body altered: %s", out)
+	}
+}
+
+func TestApplyRemarkToLinkVmessSetsPs(t *testing.T) {
+	payload := map[string]any{"v": "2", "ps": "old", "add": "1.2.3.4", "port": "443", "id": "uuid"}
+	b, _ := json.Marshal(payload)
+	link := "vmess://" + base64.StdEncoding.EncodeToString(b)
+
+	out := applyRemarkToLink(link, "NL-Node")
+	raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(out, "vmess://"))
+	if err != nil {
+		t.Fatalf("decode: %v", err)
+	}
+	var got map[string]any
+	if err := json.Unmarshal(raw, &got); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if got["ps"] != "NL-Node" {
+		t.Fatalf("ps = %v, want NL-Node", got["ps"])
+	}
+	if got["id"] != "uuid" {
+		t.Fatalf("credentials lost: %v", got)
+	}
+}
+
+func TestApplyRemarkEmptyKeepsLinkVerbatim(t *testing.T) {
+	link := "trojan://[email protected]:8443?security=tls#orig"
+	if out := applyRemarkToLink(link, ""); out != link {
+		t.Fatalf("empty remark altered link: %s", out)
+	}
+}
+
+func TestParsedExternalOutboundTagsProxy(t *testing.T) {
+	link := "vless://[email protected]:443?type=tcp&security=reality&pbk=abc&sid=12&fp=chrome#srv"
+	data := parsedExternalOutbound(link)
+	if data == nil {
+		t.Fatal("expected an outbound, got nil")
+	}
+	var ob map[string]any
+	if err := json.Unmarshal(data, &ob); err != nil {
+		t.Fatalf("unmarshal: %v", err)
+	}
+	if ob["tag"] != "proxy" {
+		t.Fatalf("tag = %v, want proxy", ob["tag"])
+	}
+	if ob["protocol"] != "vless" {
+		t.Fatalf("protocol = %v, want vless", ob["protocol"])
+	}
+}
+
+func TestDecodeSubscriptionBodyBase64(t *testing.T) {
+	plain := "vless://[email protected]:443#one\ntrojan://[email protected]:8443#two\n"
+	body := []byte(base64.StdEncoding.EncodeToString([]byte(plain)))
+	links := decodeSubscriptionBody(body)
+	if len(links) != 2 || links[0] != "vless://[email protected]:443#one" || links[1] != "trojan://[email protected]:8443#two" {
+		t.Fatalf("decoded links = %#v", links)
+	}
+}
+
+func TestDecodeSubscriptionBodyPlainSkipsComments(t *testing.T) {
+	body := []byte("# header\nvmess://abc\n\nnot-a-link\nss://def#x\n")
+	links := decodeSubscriptionBody(body)
+	if len(links) != 2 || links[0] != "vmess://abc" || links[1] != "ss://def#x" {
+		t.Fatalf("decoded links = %#v", links)
+	}
+}
+
+func TestExpandEntryLinkAppliesRemark(t *testing.T) {
+	got := expandEntry(externalLinkEntry{Kind: model.ExternalLinkKindLink, Value: "trojan://[email protected]:8443#orig", Remark: "DE"})
+	if len(got) != 1 || got[0].Name != "DE" {
+		t.Fatalf("expandEntry = %#v", got)
+	}
+}
+
+func TestClashProxyFromExternalTrojanReality(t *testing.T) {
+	link := "trojan://[email protected]:8443?type=tcp&security=reality&sni=aws.amazon.com&pbk=PBK&sid=298b44&fp=chrome#srv"
+	svc := NewSubClashService(false, "", NewSubService(false, "-io"))
+	proxy := svc.clashProxyFromExternal(link, "DE-Provider")
+	if proxy == nil {
+		t.Fatal("expected a clash proxy, got nil")
+	}
+	if proxy["type"] != "trojan" {
+		t.Fatalf("type = %v, want trojan", proxy["type"])
+	}
+	if proxy["server"] != "37.27.201.56" {
+		t.Fatalf("server = %v", proxy["server"])
+	}
+	if proxy["password"] != "provider-pass" {
+		t.Fatalf("password = %v", proxy["password"])
+	}
+	if proxy["name"] != "DE-Provider" {
+		t.Fatalf("name = %v", proxy["name"])
+	}
+	if proxy["tls"] != true {
+		t.Fatalf("expected reality→tls true, got %v", proxy["tls"])
+	}
+}

+ 133 - 0
internal/sub/external_subscription.go

@@ -0,0 +1,133 @@
+package sub
+
+import (
+	"encoding/base64"
+	"io"
+	"net/http"
+	"strings"
+	"sync"
+	"time"
+)
+
+// External subscription fetching: a "subscription" external link is a remote
+// URL whose body is a (often base64-encoded) newline list of share links. We
+// fetch it on demand, cache the decoded links briefly, and bound the request
+// with a short timeout so a slow/dead provider can't stall a client's sub.
+
+const (
+	subscriptionCacheTTL = 5 * time.Minute
+	subscriptionMaxBytes = 2 << 20 // 2 MiB
+)
+
+var subscriptionHTTPClient = &http.Client{Timeout: 6 * time.Second}
+
+type subscriptionCacheEntry struct {
+	links     []string
+	fetchedAt time.Time
+}
+
+var subscriptionCache = struct {
+	sync.Mutex
+	m map[string]subscriptionCacheEntry
+}{m: make(map[string]subscriptionCacheEntry)}
+
+// fetchSubscriptionLinks returns the share links contained in a remote
+// subscription URL, using a short-lived cache. On any failure it returns the
+// last cached value (if present) or nil — never an error, so the rest of the
+// client's subscription still renders.
+func fetchSubscriptionLinks(rawURL string) []string {
+	rawURL = strings.TrimSpace(rawURL)
+	if rawURL == "" {
+		return nil
+	}
+
+	subscriptionCache.Lock()
+	cached, ok := subscriptionCache.m[rawURL]
+	subscriptionCache.Unlock()
+	if ok && time.Since(cached.fetchedAt) < subscriptionCacheTTL {
+		return cached.links
+	}
+
+	links, err := doFetchSubscriptionLinks(rawURL)
+	if err != nil {
+		// Serve stale on error rather than dropping the client's configs.
+		if ok {
+			return cached.links
+		}
+		return nil
+	}
+
+	subscriptionCache.Lock()
+	subscriptionCache.m[rawURL] = subscriptionCacheEntry{links: links, fetchedAt: time.Now()}
+	subscriptionCache.Unlock()
+	return links
+}
+
+func doFetchSubscriptionLinks(rawURL string) ([]string, error) {
+	req, err := http.NewRequest(http.MethodGet, rawURL, nil)
+	if err != nil {
+		return nil, err
+	}
+	// Some providers gate the link body on a known client User-Agent.
+	req.Header.Set("User-Agent", "v2rayNG/1.8.5")
+	resp, err := subscriptionHTTPClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+		return nil, errBadStatus
+	}
+	body, err := io.ReadAll(io.LimitReader(resp.Body, subscriptionMaxBytes))
+	if err != nil {
+		return nil, err
+	}
+	return decodeSubscriptionBody(body), nil
+}
+
+var errBadStatus = &subError{"non-2xx subscription response"}
+
+type subError struct{ msg string }
+
+func (e *subError) Error() string { return e.msg }
+
+// decodeSubscriptionBody handles the common base64-encoded newline list as well
+// as a plain-text body, returning only the lines that look like share links.
+func decodeSubscriptionBody(body []byte) []string {
+	text := strings.TrimSpace(string(body))
+	if text == "" {
+		return nil
+	}
+	if decoded, ok := tryDecodeBase64Body(text); ok {
+		text = strings.TrimSpace(decoded)
+	}
+	lines := strings.FieldsFunc(text, func(r rune) bool { return r == '\n' || r == '\r' })
+	out := make([]string, 0, len(lines))
+	for _, ln := range lines {
+		ln = strings.TrimSpace(ln)
+		if ln == "" || strings.HasPrefix(ln, "#") {
+			continue
+		}
+		if strings.Contains(ln, "://") {
+			out = append(out, ln)
+		}
+	}
+	return out
+}
+
+func tryDecodeBase64Body(s string) (string, bool) {
+	clean := strings.Map(func(r rune) rune {
+		switch r {
+		case ' ', '\n', '\r', '\t':
+			return -1
+		}
+		return r
+	}, s)
+	if b, err := base64.StdEncoding.DecodeString(padBase64Sub(clean)); err == nil {
+		return string(b), true
+	}
+	if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(clean, "=")); err == nil {
+		return string(b), true
+	}
+	return "", false
+}

+ 29 - 1
internal/sub/json_service.go

@@ -62,9 +62,16 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 	// resolveInboundAddress call inside picks node-aware host values.
 	s.SubService.PrepareForRequest(host)
 	inbounds, err := s.SubService.getInboundsBySubId(subId)
-	if err != nil || len(inbounds) == 0 {
+	if err != nil {
 		return "", "", err
 	}
+	externalLinks, err := s.SubService.getClientExternalLinksBySubId(subId)
+	if err != nil {
+		return "", "", err
+	}
+	if len(inbounds) == 0 && len(externalLinks) == 0 {
+		return "", "", nil
+	}
 
 	var header string
 	var configArray []json_util.RawMessage
@@ -83,6 +90,27 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
 			configArray = append(configArray, s.getConfig(inbound, client, host)...)
 		}
 	}
+	for _, ext := range externalLinks {
+		for _, el := range expandEntry(ext) {
+			outbound := parsedExternalOutbound(el.Link)
+			if outbound == nil {
+				continue
+			}
+			seenEmails[ext.Email] = struct{}{}
+			remark := el.Name
+			if remark == "" {
+				remark = ext.Email
+			}
+			newOutbounds := []json_util.RawMessage{outbound}
+			newOutbounds = append(newOutbounds, s.defaultOutbounds...)
+			newConfigJson := make(map[string]any)
+			maps.Copy(newConfigJson, s.configJson)
+			newConfigJson["outbounds"] = newOutbounds
+			newConfigJson["remarks"] = remark
+			newConfig, _ := json.MarshalIndent(newConfigJson, "", "  ")
+			configArray = append(configArray, newConfig)
+		}
+	}
 
 	if len(configArray) == 0 {
 		return "", "", nil

+ 35 - 9
internal/sub/service.go

@@ -9,6 +9,7 @@ import (
 	"net"
 	"net/url"
 	"slices"
+	"strconv"
 	"strings"
 	"time"
 
@@ -147,8 +148,12 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
 	if err != nil {
 		return nil, nil, 0, traffic, err
 	}
+	externalLinks, err := s.getClientExternalLinksBySubId(subId)
+	if err != nil {
+		return nil, nil, 0, traffic, err
+	}
 
-	if len(inbounds) == 0 {
+	if len(inbounds) == 0 && len(externalLinks) == 0 {
 		return nil, nil, 0, traffic, nil
 	}
 
@@ -178,6 +183,18 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int
 			seenEmails[client.Email] = struct{}{}
 		}
 	}
+	for _, ext := range externalLinks {
+		if ext.Enable {
+			hasEnabledClient = true
+		}
+		for _, el := range expandEntry(ext) {
+			if link := applyRemarkToLink(el.Link, el.Name); link != "" {
+				result = append(result, link)
+				emails = append(emails, ext.Email)
+				seenEmails[ext.Email] = struct{}{}
+			}
+		}
+	}
 
 	uniqueEmails := make([]string, 0, len(seenEmails))
 	for e := range seenEmails {
@@ -547,7 +564,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 			params,
 			security,
 			func(dest string, port int) string {
-				return fmt.Sprintf("vless://%s@%s:%d", uuid, dest, port)
+				return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
 				return s.genRemark(inbound, email, ep["remark"].(string))
@@ -555,7 +572,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 		)
 	}
 
-	link := fmt.Sprintf("vless://%s@%s:%d", uuid, address, port)
+	link := fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(address, port))
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
 }
 
@@ -598,7 +615,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 			params,
 			security,
 			func(dest string, port int) string {
-				return fmt.Sprintf("trojan://%s@%s:%d", password, dest, port)
+				return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
 				return s.genRemark(inbound, email, ep["remark"].(string))
@@ -606,7 +623,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 		)
 	}
 
-	link := fmt.Sprintf("trojan://%s@%s:%d", password, address, port)
+	link := fmt.Sprintf("trojan://%s@%s", password, joinHostPort(address, port))
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
 }
 
@@ -621,6 +638,15 @@ func encodeUserinfo(s string) string {
 	return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
 }
 
+// joinHostPort wraps an IPv6 host in square brackets the way RFC 3986
+// requires for URI authorities, while leaving IPv4 addresses and hostnames
+// untouched. It also strips any brackets already present on the input so
+// callers don't have to normalize upstream.
+func joinHostPort(host string, port int) string {
+	host = strings.Trim(host, "[]")
+	return net.JoinHostPort(host, strconv.Itoa(port))
+}
+
 func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
 	if inbound.Protocol != model.Shadowsocks {
 		return ""
@@ -663,7 +689,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 			proxyParams,
 			security,
 			func(dest string, port int) string {
-				return fmt.Sprintf("ss://%s@%s:%d", base64.RawURLEncoding.EncodeToString([]byte(encPart)), dest, port)
+				return fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
 				return s.genRemark(inbound, email, ep["remark"].(string))
@@ -671,7 +697,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 		)
 	}
 
-	link := fmt.Sprintf("ss://%s@%s:%d", base64.RawURLEncoding.EncodeToString([]byte(encPart)), address, inbound.Port)
+	link := fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(address, inbound.Port))
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
 }
 
@@ -776,7 +802,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 			epParams := cloneStringMap(params)
 			applyExternalProxyHysteriaParams(ep, epParams)
 
-			link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, int(portF))
+			link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
 			links = append(links, buildLinkWithParams(link, epParams, s.genRemark(inbound, email, epRemark)))
 		}
 		return strings.Join(links, "\n")
@@ -787,7 +813,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 	if hopPorts := hysteriaHopPorts(stream); hopPorts != "" {
 		params["mport"] = hopPorts
 	}
-	link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.resolveInboundAddress(inbound), inbound.Port)
+	link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(s.resolveInboundAddress(inbound), inbound.Port))
 	return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
 }
 

+ 19 - 0
internal/sub/service_test.go

@@ -426,6 +426,25 @@ func TestCloneStringMap_Empty(t *testing.T) {
 	}
 }
 
+func TestJoinHostPort(t *testing.T) {
+	cases := []struct {
+		host string
+		port int
+		want string
+	}{
+		{"example.com", 443, "example.com:443"},
+		{"1.2.3.4", 443, "1.2.3.4:443"},
+		{"2001:db8::1", 443, "[2001:db8::1]:443"},
+		{"[2001:db8::1]", 443, "[2001:db8::1]:443"},
+		{"2001:db8::1", 8080, "[2001:db8::1]:8080"},
+	}
+	for _, c := range cases {
+		if got := joinHostPort(c.host, c.port); got != c.want {
+			t.Fatalf("joinHostPort(%q, %d) = %q, want %q", c.host, c.port, got, c.want)
+		}
+	}
+}
+
 func TestGetHostFromXFH_HostOnly(t *testing.T) {
 	got, err := getHostFromXFH("example.com")
 	if err != nil {

+ 34 - 0
internal/web/cadence_test.go

@@ -0,0 +1,34 @@
+package web
+
+import (
+	"testing"
+
+	"github.com/robfig/cron/v3"
+)
+
+// All centralized background-job cadences must remain valid cron specs. This is
+// the guard for the "single tuning surface" refactor: editing a cadence to an
+// invalid spec fails here instead of silently dropping a job at startup.
+//
+// NOTE: package web embeds the built frontend (//go:embed all:dist), so this
+// test compiles only after `npm run build` has populated web/dist — the normal
+// repo build flow.
+func TestJobCadencesAreValidCronSpecs(t *testing.T) {
+	cadences := map[string]string{
+		"cadenceXrayRunning":   cadenceXrayRunning,
+		"cadenceXrayRestart":   cadenceXrayRestart,
+		"cadenceXrayTraffic":   cadenceXrayTraffic,
+		"cadenceMtproto":       cadenceMtproto,
+		"cadenceClientIPScan":  cadenceClientIPScan,
+		"cadenceNodeHeartbeat": cadenceNodeHeartbeat,
+		"cadenceNodeTraffic":   cadenceNodeTraffic,
+		"cadenceOutboundSub":   cadenceOutboundSub,
+		"cadenceCheckHash":     cadenceCheckHash,
+		"cadenceCPUAlarm":      cadenceCPUAlarm,
+	}
+	for name, spec := range cadences {
+		if _, err := cron.ParseStandard(spec); err != nil {
+			t.Errorf("%s = %q is not a valid cron spec: %v", name, spec, err)
+		}
+	}
+}

+ 26 - 1
internal/web/controller/client.go

@@ -59,6 +59,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/del/:email", a.delete)
 	g.POST("/:email/attach", a.attach)
 	g.POST("/:email/detach", a.detach)
+	g.POST("/:email/externalLinks", a.setExternalLinks)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/delDepleted", a.delDepleted)
 	g.POST("/bulkAdjust", a.bulkAdjust)
@@ -112,6 +113,11 @@ func (a *ClientController) get(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "get"), err)
 		return
 	}
+	externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
 	flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "get"), err)
@@ -125,7 +131,7 @@ func (a *ClientController) get(c *gin.Context) {
 	if t, tErr := a.inboundService.GetClientTrafficByEmail(email); tErr == nil && t != nil {
 		usedTraffic = t.Up + t.Down
 	}
-	jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "usedTraffic": usedTraffic}, nil)
+	jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds, "externalLinks": externalLinks, "usedTraffic": usedTraffic}, nil)
 }
 
 func (a *ClientController) create(c *gin.Context) {
@@ -185,6 +191,10 @@ type attachDetachBody struct {
 	InboundIds []int `json:"inboundIds"`
 }
 
+type externalLinksBody struct {
+	ExternalLinks []service.ExternalLinkInput `json:"externalLinks"`
+}
+
 func (a *ClientController) attach(c *gin.Context) {
 	email := c.Param("email")
 	var body attachDetachBody
@@ -204,6 +214,21 @@ func (a *ClientController) attach(c *gin.Context) {
 	notifyClientsChanged()
 }
 
+func (a *ClientController) setExternalLinks(c *gin.Context) {
+	email := c.Param("email")
+	var body externalLinksBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.clientService.SetExternalLinksByEmail(email, body.ExternalLinks); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
+	notifyClientsChanged()
+}
+
 func (a *ClientController) resetAllTraffics(c *gin.Context) {
 	needRestart, err := a.clientService.ResetAllTraffics()
 	if err != nil {

+ 51 - 6
internal/web/controller/node.go

@@ -9,6 +9,7 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 
@@ -17,6 +18,7 @@ import (
 
 type NodeController struct {
 	nodeService service.NodeService
+	xrayService service.XrayService
 }
 
 func NewNodeController(g *gin.RouterGroup) *NodeController {
@@ -96,14 +98,25 @@ func (a *NodeController) add(c *gin.Context) {
 	if !ok {
 		return
 	}
-	if err := a.ensureReachable(c, n); err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
-		return
+	if n.OutboundTag == "" {
+		if err := a.ensureReachable(c, n); err != nil {
+			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
+			return
+		}
 	}
 	if err := a.nodeService.Create(n); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
 		return
 	}
+	if n.OutboundTag != "" {
+		if err := a.xrayService.RestartXray(false); err != nil {
+			logger.Warning("apply node outbound bridge failed:", err)
+		}
+		if err := a.ensureReachable(c, n); err != nil {
+			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
+			return
+		}
+	}
 	jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
 }
 
@@ -117,14 +130,30 @@ func (a *NodeController) update(c *gin.Context) {
 	if !ok {
 		return
 	}
-	if err := a.ensureReachable(c, n); err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
+	old, err := a.nodeService.GetById(id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
 		return
 	}
+	if n.OutboundTag == "" && old.OutboundTag == "" {
+		if err := a.ensureReachable(c, n); err != nil {
+			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
+			return
+		}
+	}
 	if err := a.nodeService.Update(id, n); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 		return
 	}
+	if n.OutboundTag != old.OutboundTag {
+		if err := a.xrayService.RestartXray(false); err != nil {
+			logger.Warning("apply node outbound bridge change failed:", err)
+		}
+		if err := a.ensureReachable(c, n); err != nil {
+			jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
+			return
+		}
+	}
 	jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
 }
 
@@ -154,10 +183,20 @@ func (a *NodeController) setEnable(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 		return
 	}
+	n, err := a.nodeService.GetById(id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
+		return
+	}
 	if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
 		return
 	}
+	if n.OutboundTag != "" {
+		if err := a.xrayService.RestartXray(false); err != nil {
+			logger.Warning("apply node enable change failed:", err)
+		}
+	}
 	jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
 }
 
@@ -188,7 +227,13 @@ func (a *NodeController) test(c *gin.Context) {
 
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
 	defer cancel()
-	patch, err := a.nodeService.Probe(ctx, n)
+	var patch service.HeartbeatPatch
+	var err error
+	if n.OutboundTag != "" {
+		patch, err = a.nodeService.ProbeWithOutbound(ctx, n, n.OutboundTag)
+	} else {
+		patch, err = a.nodeService.Probe(ctx, n)
+	}
 	jsonObj(c, patch.ToUI(err == nil), nil)
 }
 

+ 24 - 4
internal/web/runtime/manager.go

@@ -8,11 +8,16 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 )
 
+type NodeEgressResolver interface {
+	NodeEgressProxyURL(nodeID int) string
+}
+
 type Manager struct {
 	local Runtime
 
-	mu      sync.RWMutex
-	remotes map[int]*Remote
+	mu             sync.RWMutex
+	remotes        map[int]*Remote
+	egressResolver NodeEgressResolver
 }
 
 func NewManager(localDeps LocalDeps) *Manager {
@@ -22,6 +27,21 @@ func NewManager(localDeps LocalDeps) *Manager {
 	}
 }
 
+func (m *Manager) SetNodeEgressResolver(r NodeEgressResolver) {
+	m.mu.Lock()
+	defer m.mu.Unlock()
+	m.egressResolver = r
+}
+
+func (m *Manager) NodeEgressProxyURL(nodeID int) string {
+	m.mu.RLock()
+	defer m.mu.RUnlock()
+	if m.egressResolver == nil {
+		return ""
+	}
+	return m.egressResolver.NodeEgressProxyURL(nodeID)
+}
+
 func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
 	if nodeID == nil {
 		return m.local, nil
@@ -45,7 +65,7 @@ func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
 	if !n.Enable {
 		return nil, errors.New("node " + n.Name + " is disabled")
 	}
-	rt := NewRemote(n)
+	rt := NewRemote(n, m.egressResolver)
 	m.remotes[*nodeID] = rt
 	return rt, nil
 }
@@ -68,7 +88,7 @@ func (m *Manager) RemoteFor(node *model.Node) (*Remote, error) {
 	if rt, ok := m.remotes[node.Id]; ok {
 		return rt, nil
 	}
-	rt := NewRemote(node)
+	rt := NewRemote(node, m.egressResolver)
 	m.remotes[node.Id] = rt
 	return rt, nil
 }

+ 11 - 4
internal/web/runtime/remote.go

@@ -40,6 +40,8 @@ type Remote struct {
 	clientOnce sync.Once
 	client     *http.Client
 	clientErr  error
+
+	egressResolver NodeEgressResolver
 }
 
 type RemoteInboundOption struct {
@@ -49,10 +51,11 @@ type RemoteInboundOption struct {
 	Port     int            `json:"port"`
 }
 
-func NewRemote(n *model.Node) *Remote {
+func NewRemote(n *model.Node, r NodeEgressResolver) *Remote {
 	return &Remote{
-		node:          n,
-		remoteIDByTag: make(map[string]int),
+		node:           n,
+		remoteIDByTag:  make(map[string]int),
+		egressResolver: r,
 	}
 }
 
@@ -62,7 +65,11 @@ func (r *Remote) Name() string { return "node:" + r.node.Name }
 // verify mode, so Remote ops don't fall back to system CA on skip/pin (#5264).
 func (r *Remote) httpClient() (*http.Client, error) {
 	r.clientOnce.Do(func() {
-		r.client, r.clientErr = HTTPClientForNode(r.node)
+		proxyURL := ""
+		if r.node.OutboundTag != "" && r.egressResolver != nil {
+			proxyURL = r.egressResolver.NodeEgressProxyURL(r.node.Id)
+		}
+		r.client, r.clientErr = HTTPClientForNode(r.node, proxyURL)
 	})
 	return r.client, r.clientErr
 }

+ 1 - 1
internal/web/runtime/remote_test.go

@@ -26,7 +26,7 @@ func TestCacheGetTag_PrefixAgnostic(t *testing.T) {
 	}
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
-			r := NewRemote(&model.Node{Id: 1, Name: "n1"})
+			r := NewRemote(&model.Node{Id: 1, Name: "n1"}, nil)
 			r.cacheSet(c.cacheTag, 7)
 			id, ok := r.cacheGetTag(c.lookup)
 			if ok != c.wantFound || id != c.wantID {

+ 39 - 14
internal/web/runtime/tls_client.go

@@ -12,6 +12,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 )
 
@@ -26,19 +27,51 @@ var defaultNodeHTTPClient = &http.Client{
 	},
 }
 
-// HTTPClientForNode returns the node's HTTP client honoring its TLS verify mode
-// (verify→system CA, skip→no check, pin→leaf SHA-256). Used by both the probe
-// and every Remote op so they can't disagree on a self-signed node (#5264).
-func HTTPClientForNode(n *model.Node) (*http.Client, error) {
+func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 	mode := n.TlsVerifyMode
 	if mode == "" {
 		mode = "verify"
 	}
+	if proxyURL != "" {
+		client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout)
+		if err != nil {
+			return nil, err
+		}
+		if mode == "verify" || n.Scheme == "http" {
+			return client, nil
+		}
+		transport, ok := client.Transport.(*http.Transport)
+		if !ok {
+			return client, nil
+		}
+		tlsCfg, err := tlsConfigForNode(n)
+		if err != nil {
+			return nil, err
+		}
+		transport.TLSClientConfig = tlsCfg
+		return client, nil
+	}
 	if mode == "verify" || n.Scheme == "http" {
 		return defaultNodeHTTPClient, nil
 	}
+	tlsCfg, err := tlsConfigForNode(n)
+	if err != nil {
+		return nil, err
+	}
+	return &http.Client{
+		Transport: &http.Transport{
+			MaxIdleConns:        64,
+			MaxIdleConnsPerHost: 4,
+			IdleConnTimeout:     60 * time.Second,
+			DialContext:         netsafe.SSRFGuardedDialContext,
+			TLSClientConfig:     tlsCfg,
+		},
+	}, nil
+}
+
+func tlsConfigForNode(n *model.Node) (*tls.Config, error) {
 	tlsCfg := &tls.Config{InsecureSkipVerify: true} // lgtm[go/disabled-certificate-check]
-	if mode == "pin" {
+	if n.TlsVerifyMode == "pin" {
 		want, err := DecodeCertPin(n.PinnedCertSha256)
 		if err != nil {
 			return nil, err
@@ -54,15 +87,7 @@ func HTTPClientForNode(n *model.Node) (*http.Client, error) {
 			return nil
 		}
 	}
-	return &http.Client{
-		Transport: &http.Transport{
-			MaxIdleConns:        64,
-			MaxIdleConnsPerHost: 4,
-			IdleConnTimeout:     60 * time.Second,
-			DialContext:         netsafe.SSRFGuardedDialContext,
-			TLSClientConfig:     tlsCfg,
-		},
-	}, nil
+	return tlsCfg, nil
 }
 
 // DecodeCertPin decodes a SHA-256 cert pin given as base64 (Xray's

+ 4 - 4
internal/web/runtime/tls_client_test.go

@@ -72,7 +72,7 @@ func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
 	}
 	for _, c := range cases {
 		t.Run(c.name, func(t *testing.T) {
-			r := NewRemote(nodeForServer(t, srv, c.mode, c.pin))
+			r := NewRemote(nodeForServer(t, srv, c.mode, c.pin), nil)
 			_, err := r.ListInboundOptions(context.Background())
 			if c.wantErr && err == nil {
 				t.Fatalf("mode %q: expected error, got nil", c.mode)
@@ -87,7 +87,7 @@ func TestRemoteHonorsTLSVerifyMode(t *testing.T) {
 // The lazily-built client is cached for the Remote's lifetime so repeated
 // operations reuse one pooled transport rather than rebuilding TLS each call.
 func TestRemoteClientCached(t *testing.T) {
-	r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"})
+	r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"}, nil)
 	c1, err1 := r.httpClient()
 	c2, err2 := r.httpClient()
 	if err1 != nil || err2 != nil {
@@ -105,7 +105,7 @@ func TestHTTPClientForNodeVerifyShared(t *testing.T) {
 		{Scheme: "https", TlsVerifyMode: ""},
 		{Scheme: "http", TlsVerifyMode: "skip"},
 	} {
-		c, err := HTTPClientForNode(n)
+		c, err := HTTPClientForNode(n, "")
 		if err != nil {
 			t.Fatalf("HTTPClientForNode(%+v): %v", n, err)
 		}
@@ -116,7 +116,7 @@ func TestHTTPClientForNodeVerifyShared(t *testing.T) {
 }
 
 func TestHTTPClientForNodePinInvalid(t *testing.T) {
-	if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}); err == nil {
+	if _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: "not-a-pin"}, ""); err == nil {
 		t.Fatal("expected error for invalid pin")
 	}
 }

+ 3 - 0
internal/web/service/client_crud.go

@@ -407,6 +407,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 	if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
 		return needRestart, err
 	}
+	if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
+		return needRestart, err
+	}
 	if !keepTraffic && existing.Email != "" {
 		if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
 			return needRestart, err

+ 103 - 0
internal/web/service/client_external_link.go

@@ -0,0 +1,103 @@
+package service
+
+import (
+	"net/url"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
+
+	"gorm.io/gorm"
+)
+
+// ExternalLinkInput is one row from the client form's Links tab.
+type ExternalLinkInput struct {
+	Kind   string `json:"kind"`
+	Value  string `json:"value"`
+	Remark string `json:"remark"`
+}
+
+func (s *ClientService) GetExternalLinksForRecord(id int) ([]model.ClientExternalLink, error) {
+	var rows []model.ClientExternalLink
+	if err := database.GetDB().
+		Where("client_id = ?", id).
+		Order("sort_index ASC, id ASC").
+		Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	return rows, nil
+}
+
+// normalizeExternalLinks validates and orders the incoming rows. A "link" must
+// parse to a supported share-link scheme; a "subscription" must be an http(s)
+// URL. Blank values are dropped; an invalid value is a hard error so the
+// operator gets immediate feedback instead of a silently missing config.
+func normalizeExternalLinks(inputs []ExternalLinkInput) ([]model.ClientExternalLink, error) {
+	out := make([]model.ClientExternalLink, 0, len(inputs))
+	for _, in := range inputs {
+		value := strings.TrimSpace(in.Value)
+		if value == "" {
+			continue
+		}
+		kind := strings.TrimSpace(in.Kind)
+		switch kind {
+		case model.ExternalLinkKindSubscription:
+			if !isHTTPURL(value) {
+				return nil, common.NewError("external subscription must be an http(s) URL: " + value)
+			}
+		case model.ExternalLinkKindLink, "":
+			kind = model.ExternalLinkKindLink
+			if _, err := link.ParseLink(value); err != nil {
+				return nil, common.NewError("unsupported or invalid share link: " + value)
+			}
+		default:
+			return nil, common.NewError("unknown external link kind: " + kind)
+		}
+		out = append(out, model.ClientExternalLink{
+			Kind:      kind,
+			Value:     value,
+			Remark:    strings.TrimSpace(in.Remark),
+			SortIndex: len(out),
+		})
+	}
+	return out, nil
+}
+
+func isHTTPURL(s string) bool {
+	u, err := url.Parse(s)
+	return err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
+}
+
+// SetExternalLinksForRecord replaces a client's entire external-link set.
+func (s *ClientService) SetExternalLinksForRecord(id int, inputs []ExternalLinkInput) error {
+	rows, err := normalizeExternalLinks(inputs)
+	if err != nil {
+		return err
+	}
+	db := database.GetDB()
+	return db.Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
+			return err
+		}
+		for i := range rows {
+			rows[i].ClientId = id
+			if err := tx.Create(&rows[i]).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+func (s *ClientService) SetExternalLinksByEmail(email string, inputs []ExternalLinkInput) error {
+	if strings.TrimSpace(email) == "" {
+		return common.NewError("client email is required")
+	}
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return err
+	}
+	return s.SetExternalLinksForRecord(rec.Id, inputs)
+}

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

@@ -109,7 +109,7 @@ func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T)
 	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
 
 	svc := InboundService{}
-	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
+	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
 		t.Fatalf("ReconcileNode: %v", err)
 	}
 
@@ -133,7 +133,7 @@ func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) {
 	seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
 
 	svc := InboundService{}
-	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node), node); err != nil {
+	if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil {
 		t.Fatalf("ReconcileNode: %v", err)
 	}
 

+ 122 - 3
internal/web/service/node.go

@@ -17,9 +17,12 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 type HeartbeatPatch struct {
@@ -339,6 +342,7 @@ func (s *NodeService) Update(id int, in *model.Node) error {
 		"pinned_cert_sha256":    in.PinnedCertSha256,
 		"inbound_sync_mode":     in.InboundSyncMode,
 		"inbound_tags":          string(inboundTagsJSON),
+		"outbound_tag":          in.OutboundTag,
 	}
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
@@ -353,7 +357,7 @@ func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node
 	if err := s.normalize(n); err != nil {
 		return nil, err
 	}
-	return runtime.NewRemote(n).ListInboundOptions(ctx)
+	return runtime.NewRemote(n, nil).ListInboundOptions(ctx)
 }
 
 // EnsureInboundTagAllowed adds a panel-managed inbound's tag to the node's
@@ -427,7 +431,13 @@ func (s *NodeService) Delete(id int) error {
 
 func (s *NodeService) SetEnable(id int, enable bool) error {
 	db := database.GetDB()
-	return db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error
+	if err := db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error; err != nil {
+		return err
+	}
+	if mgr := runtime.GetManager(); mgr != nil {
+		mgr.InvalidateNode(id)
+	}
+	return nil
 }
 
 // GetWebCertFiles asks a node for its own web TLS certificate/key file paths,
@@ -588,6 +598,115 @@ func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds i
 }
 
 func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
+	proxyURL := ""
+	if n.OutboundTag != "" {
+		if mgr := runtime.GetManager(); mgr != nil {
+			proxyURL = mgr.NodeEgressProxyURL(n.Id)
+		}
+	}
+	return s.probe(ctx, n, proxyURL)
+}
+
+func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outboundTag string) (HeartbeatPatch, error) {
+	if outboundTag == "" {
+		return s.Probe(ctx, n)
+	}
+	proc := XrayProcess()
+	if proc == nil || !proc.IsRunning() {
+		return s.Probe(ctx, n)
+	}
+	apiPort := proc.GetAPIPort()
+	if apiPort <= 0 {
+		return s.Probe(ctx, n)
+	}
+
+	listener, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		return s.Probe(ctx, n)
+	}
+	port := listener.Addr().(*net.TCPAddr).Port
+	listener.Close()
+
+	tag := fmt.Sprintf("node-test-%d-%d", n.Id, time.Now().UnixNano())
+	proxyURL := fmt.Sprintf("socks5://127.0.0.1:%d", port)
+
+	inboundJSON, err := json.Marshal(xray.InboundConfig{
+		Listen:   json_util.RawMessage(`"127.0.0.1"`),
+		Port:     port,
+		Protocol: "socks",
+		Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
+		Tag:      tag,
+	})
+	if err != nil {
+		return s.Probe(ctx, n)
+	}
+
+	cfg := proc.GetConfig()
+	routing := map[string]any{}
+	if len(cfg.RouterConfig) > 0 {
+		_ = json.Unmarshal(cfg.RouterConfig, &routing)
+	}
+	rules, _ := routing["rules"].([]any)
+	rule := map[string]any{
+		"type":       "field",
+		"inboundTag": []any{tag},
+	}
+	if routingTagIsBalancer(routing, outboundTag) {
+		rule["balancerTag"] = outboundTag
+	} else {
+		rule["outboundTag"] = outboundTag
+	}
+	routing["rules"] = append([]any{rule}, rules...)
+	routingJSON, err := json.Marshal(routing)
+	if err != nil {
+		return s.Probe(ctx, n)
+	}
+	originalRoutingJSON := cfg.RouterConfig
+
+	api := xray.XrayAPI{}
+	if err := api.Init(apiPort); err != nil {
+		return s.Probe(ctx, n)
+	}
+	defer api.Close()
+
+	if err := api.AddInbound(inboundJSON); err != nil {
+		return s.Probe(ctx, n)
+	}
+	removed := false
+	defer func() {
+		if removed {
+			return
+		}
+		if err := api.DelInbound(tag); err != nil {
+			logger.Warning("remove temp node test inbound failed:", err)
+		}
+	}()
+
+	if err := api.ApplyRoutingConfig(routingJSON); err != nil {
+		return s.Probe(ctx, n)
+	}
+	defer func() {
+		restore := originalRoutingJSON
+		if len(restore) == 0 {
+			restore = []byte("{}")
+		}
+		if err := api.ApplyRoutingConfig(restore); err != nil {
+			logger.Warning("restore routing after node test failed:", err)
+		}
+	}()
+
+	patch, err := s.probe(ctx, n, proxyURL)
+	removed = true
+	if delErr := api.DelInbound(tag); delErr != nil {
+		logger.Warning("remove temp node test inbound failed:", delErr)
+	}
+	if err != nil {
+		return patch, err
+	}
+	return patch, nil
+}
+
+func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) {
 	patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
 
 	addr, err := netsafe.NormalizeHost(n.Address)
@@ -621,7 +740,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
 	}
 	req.Header.Set("Accept", "application/json")
 
-	client, err := runtime.HTTPClientForNode(n)
+	client, err := runtime.HTTPClientForNode(n, proxyURL)
 	if err != nil {
 		patch.LastError = err.Error()
 		return patch, err

+ 20 - 0
internal/web/service/setting.go

@@ -434,6 +434,26 @@ func (s *SettingService) PanelEgressProxyURL() string {
 	return ""
 }
 
+func (s *SettingService) NodeEgressProxyURL(nodeID int) string {
+	tag := NodeEgressInboundTag(nodeID)
+	proc := XrayProcess()
+	if proc == nil || !proc.IsRunning() {
+		logger.Warning("node outbound [", tag, "] is set but Xray is not running, using a direct connection")
+		return ""
+	}
+	cfg := proc.GetConfig()
+	if cfg == nil {
+		return ""
+	}
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag == tag {
+			return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
+		}
+	}
+	logger.Warning("node outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
+	return ""
+}
+
 // NewProxiedHTTPClient returns an HTTP client that routes the panel's own
 // outbound requests through the configured panel outbound (via the loopback
 // SOCKS bridge in the running Xray). When the feature is off or the bridge

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

@@ -3,6 +3,7 @@ package service
 import (
 	"encoding/json"
 	"errors"
+	"fmt"
 	"path"
 	"path/filepath"
 	"runtime"
@@ -31,6 +32,7 @@ var (
 type XrayService struct {
 	inboundService InboundService
 	settingService SettingService
+	nodeService    NodeService
 	xrayAPI        xray.XrayAPI
 }
 
@@ -118,6 +120,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 	xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
 	xrayConfig.API = ensureAPIServices(xrayConfig.API)
 	xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
+	xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig)
 
 	_, _, _ = s.inboundService.AddTraffic(nil, nil)
 
@@ -296,6 +299,13 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		injectPanelEgress(xrayConfig, egressTag)
 	}
 
+	nodes, err := s.nodeService.GetAll()
+	if err != nil {
+		logger.Warning("read nodes for egress injection failed:", err)
+	} else {
+		injectNodeEgresses(xrayConfig, nodes)
+	}
+
 	return xrayConfig, nil
 }
 
@@ -372,6 +382,88 @@ func injectPanelEgress(cfg *xray.Config, outboundTag string) {
 	})
 }
 
+// NodeEgressInboundTag returns the loopback SOCKS inbound tag for a given node.
+func NodeEgressInboundTag(nodeID int) string {
+	return fmt.Sprintf("node-egress-%d", nodeID)
+}
+
+// nodeEgressBasePort is the first port tried for node egress bridges.
+const nodeEgressBasePort = 62800
+
+// injectNodeEgresses appends a loopback SOCKS inbound per enabled node that has
+// an OutboundTag, and prepends a routing rule sending that inbound's traffic to
+// the selected outbound tag. These bridges are hot-appliable.
+func injectNodeEgresses(cfg *xray.Config, nodes []*model.Node) {
+	routing := map[string]any{}
+	if len(cfg.RouterConfig) > 0 {
+		if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+			logger.Warning("node egress: routing section is unparsable, skipping injection:", err)
+			return
+		}
+	}
+
+	used := make(map[int]struct{}, len(cfg.InboundConfigs))
+	usedTags := make(map[string]struct{}, len(cfg.InboundConfigs))
+	for i := range cfg.InboundConfigs {
+		used[cfg.InboundConfigs[i].Port] = struct{}{}
+		usedTags[cfg.InboundConfigs[i].Tag] = struct{}{}
+	}
+
+	rules, _ := routing["rules"].([]any)
+	newRules := make([]any, 0)
+
+	for _, n := range nodes {
+		if !n.Enable || n.OutboundTag == "" {
+			continue
+		}
+		tag := NodeEgressInboundTag(n.Id)
+		if _, exists := usedTags[tag]; exists {
+			logger.Warning("node egress: inbound tag [", tag, "] already exists, skipping")
+			continue
+		}
+		usedTags[tag] = struct{}{}
+
+		rule := map[string]any{
+			"type":       "field",
+			"inboundTag": []any{tag},
+		}
+		if routingTagIsBalancer(routing, n.OutboundTag) {
+			rule["balancerTag"] = n.OutboundTag
+		} else {
+			rule["outboundTag"] = n.OutboundTag
+		}
+		newRules = append(newRules, rule)
+
+		port := nodeEgressBasePort + n.Id
+		for {
+			if _, taken := used[port]; !taken {
+				break
+			}
+			port++
+		}
+		used[port] = struct{}{}
+
+		cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
+			Listen:   json_util.RawMessage(`"127.0.0.1"`),
+			Port:     port,
+			Protocol: "socks",
+			Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
+			Tag:      tag,
+		})
+	}
+
+	if len(newRules) == 0 {
+		return
+	}
+	routing["rules"] = append(newRules, rules...)
+	newRouting, err := json.Marshal(routing)
+	if err != nil {
+		logger.Warning("node egress: failed to rebuild routing section, skipping injection:", err)
+		return
+	}
+	cfg.RouterConfig = json_util.RawMessage(newRouting)
+}
+
 // routingTagIsBalancer reports whether tag names a balancer in the parsed
 // routing section. The panel-egress rule targets a balancer via balancerTag and
 // a concrete outbound via outboundTag, so the caller picks the key from this.
@@ -620,6 +712,59 @@ func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
 	return out
 }
 
+// stripDisabledRules removes routing rules marked `enabled: false` from the
+// generated runtime config and strips the panel-only `enabled` key from the
+// rest, since xray-core has no such field. The internal api rule is always
+// kept (see isApiRule) so traffic stats can't be toggled off. The stored
+// template is untouched — only the generated config is filtered.
+func stripDisabledRules(routerCfg json_util.RawMessage) json_util.RawMessage {
+	if len(routerCfg) == 0 {
+		return routerCfg
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(routerCfg, &parsed); err != nil {
+		return routerCfg
+	}
+	rules, ok := parsed["rules"].([]any)
+	if !ok || len(rules) == 0 {
+		return routerCfg
+	}
+
+	var activeRules []any
+	changed := false
+	for _, rawRule := range rules {
+		rule, ok := rawRule.(map[string]any)
+		if !ok {
+			activeRules = append(activeRules, rawRule)
+			continue
+		}
+
+		if enabledRaw, exists := rule["enabled"]; exists {
+			// The internal api rule carries traffic stats and must never be
+			// dropped, even if it was somehow marked disabled.
+			enabled, ok := enabledRaw.(bool)
+			if ok && !enabled && !isApiRule(rule) {
+				changed = true
+				continue
+			}
+			delete(rule, "enabled")
+			changed = true
+		}
+		activeRules = append(activeRules, rule)
+	}
+
+	if !changed {
+		return routerCfg
+	}
+
+	parsed["rules"] = activeRules
+	out, err := json.Marshal(parsed)
+	if err != nil {
+		return routerCfg
+	}
+	return out
+}
+
 // GetXrayTraffic fetches the current traffic statistics from the running Xray process.
 func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
 	if !s.IsXrayRunning() {

+ 35 - 26
internal/web/service/xray_setting.go

@@ -237,6 +237,7 @@ func EnsureStatsRouting(raw string) (string, error) {
 			"outboundTag": "api",
 		}
 	}
+	delete(apiRule, "enabled")
 	rules = append([]map[string]any{apiRule}, rules...)
 
 	rulesJSON, err := json.Marshal(rules)
@@ -258,35 +259,43 @@ func EnsureStatsRouting(raw string) (string, error) {
 	return string(out), nil
 }
 
+// isApiRule reports whether a routing rule targets the internal api inbound
+// (inboundTag contains "api" and outboundTag is "api").
+func isApiRule(rule map[string]any) bool {
+	if outTag, _ := rule["outboundTag"].(string); outTag != "api" {
+		return false
+	}
+	raw, ok := rule["inboundTag"]
+	if !ok {
+		return false
+	}
+	// inboundTag is usually []string but can come as []any from a
+	// roundtrip through map[string]any. Accept both shapes.
+	switch tags := raw.(type) {
+	case []any:
+		for _, t := range tags {
+			if s, ok := t.(string); ok && s == "api" {
+				return true
+			}
+		}
+	case []string:
+		if slices.Contains(tags, "api") {
+			return true
+		}
+	case string:
+		if tags == "api" {
+			return true
+		}
+	}
+	return false
+}
+
 // findApiRule returns the index of the routing rule that targets the
-// internal api inbound (inboundTag contains "api" and outboundTag is
-// "api"), or -1 if no such rule exists.
+// internal api inbound, or -1 if no such rule exists.
 func findApiRule(rules []map[string]any) int {
 	for i, rule := range rules {
-		if outTag, _ := rule["outboundTag"].(string); outTag != "api" {
-			continue
-		}
-		raw, ok := rule["inboundTag"]
-		if !ok {
-			continue
-		}
-		// inboundTag is usually []string but can come as []any from a
-		// roundtrip through map[string]any. Accept both shapes.
-		switch tags := raw.(type) {
-		case []any:
-			for _, t := range tags {
-				if s, ok := t.(string); ok && s == "api" {
-					return i
-				}
-			}
-		case []string:
-			if slices.Contains(tags, "api") {
-				return i
-			}
-		case string:
-			if tags == "api" {
-				return i
-			}
+		if isApiRule(rule) {
+			return i
 		}
 	}
 	return -1

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

@@ -0,0 +1,88 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+)
+
+// rulesOf unmarshals a router config and returns its rules for assertions.
+func rulesOf(t *testing.T, raw json_util.RawMessage) []map[string]any {
+	t.Helper()
+	var parsed struct {
+		Rules []map[string]any `json:"rules"`
+	}
+	if err := json.Unmarshal(raw, &parsed); err != nil {
+		t.Fatalf("unmarshal result: %v", err)
+	}
+	return parsed.Rules
+}
+
+func TestStripDisabledRules(t *testing.T) {
+	t.Run("empty config is returned untouched", func(t *testing.T) {
+		if got := stripDisabledRules(nil); got != nil {
+			t.Fatalf("expected nil passthrough, got %s", got)
+		}
+	})
+
+	t.Run("missing or empty rules is a no-op", func(t *testing.T) {
+		in := json_util.RawMessage(`{"domainStrategy":"AsIs"}`)
+		if got := stripDisabledRules(in); string(got) != string(in) {
+			t.Fatalf("config without rules was modified: %s", got)
+		}
+	})
+
+	t.Run("drops disabled rules and strips the enabled key from the rest", func(t *testing.T) {
+		in := json_util.RawMessage(`{"rules":[
+			{"outboundTag":"direct","domain":["a.com"],"enabled":true},
+			{"outboundTag":"block","domain":["b.com"],"enabled":false},
+			{"outboundTag":"proxy","domain":["c.com"]}
+		]}`)
+		rules := rulesOf(t, stripDisabledRules(in))
+		if len(rules) != 2 {
+			t.Fatalf("expected 2 active rules, got %d: %v", len(rules), rules)
+		}
+		for _, r := range rules {
+			if _, ok := r["enabled"]; ok {
+				t.Fatalf("enabled key must not survive into the runtime config: %v", r)
+			}
+		}
+		if rules[0]["outboundTag"] != "direct" || rules[1]["outboundTag"] != "proxy" {
+			t.Fatalf("kept rules or their order are wrong: %v", rules)
+		}
+	})
+
+	t.Run("never drops the api rule even when marked disabled", func(t *testing.T) {
+		in := json_util.RawMessage(`{"rules":[
+			{"inboundTag":["api"],"outboundTag":"api","enabled":false},
+			{"outboundTag":"block","domain":["b.com"],"enabled":false}
+		]}`)
+		rules := rulesOf(t, stripDisabledRules(in))
+		if len(rules) != 1 {
+			t.Fatalf("expected only the api rule to survive, got %d: %v", len(rules), rules)
+		}
+		if rules[0]["outboundTag"] != "api" {
+			t.Fatalf("api rule was dropped: %v", rules)
+		}
+		if _, ok := rules[0]["enabled"]; ok {
+			t.Fatalf("enabled key must be stripped from the api rule too: %v", rules[0])
+		}
+	})
+
+	t.Run("non-object rules pass through, disabled object is dropped", func(t *testing.T) {
+		in := json_util.RawMessage(`{"rules":["weird",{"outboundTag":"block","enabled":false}]}`)
+		var parsed struct {
+			Rules []any `json:"rules"`
+		}
+		if err := json.Unmarshal(stripDisabledRules(in), &parsed); err != nil {
+			t.Fatal(err)
+		}
+		if len(parsed.Rules) != 1 {
+			t.Fatalf("expected 1 surviving rule (the string), got %v", parsed.Rules)
+		}
+		if s, _ := parsed.Rules[0].(string); s != "weird" {
+			t.Fatalf("non-object rule should be preserved, got %v", parsed.Rules[0])
+		}
+	})
+}

+ 10 - 0
internal/web/translation/ar-EG.json

@@ -289,6 +289,7 @@
       "port": "المنفذ",
       "portMap": "تعيين المنفذ",
       "traffic": "حركة المرور",
+      "speed": "السرعة",
       "details": "تفاصيل",
       "transportConfig": "النقل",
       "expireDate": "المدة",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "أساسي",
       "tabCredentials": "بيانات الاعتماد",
+      "tabLinks": "الروابط",
+      "linksHint": "أضف روابط مشاركة من جهات خارجية وعناوين اشتراك خارجية لتضمينها في اشتراك هذا العميل.",
+      "addExternalLink": "إضافة رابط خارجي",
+      "addExternalSubscription": "إضافة اشتراك خارجي",
+      "noExternalLinks": "لا توجد روابط خارجية بعد.",
+      "noExternalSubscriptions": "لا توجد اشتراكات خارجية بعد.",
       "add": "إضافة عميل",
       "edit": "تعديل العميل",
       "submitAdd": "إضافة عميل",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
       "allowPrivateAddress": "السماح بالعنوان الخاص",
       "allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
+      "outboundTag": "اتصال صادر",
+      "outboundTagHint": "وجه حركة مرور API اللوحة لهذه العقدة عبر outbound Xray المحدد. يتم إضافة inbound جسر loopback تلقائيًا إلى التكوين قيد التشغيل وتطبيقه مباشرة. اتركه فارغًا للاتصال المباشر.",
+      "outboundTagPlaceholder": "اتصال مباشر",
       "inboundSyncMode": "استيراد الاتصالات الواردة",
       "inboundSyncModeHint": "اختر الاتصالات الواردة التي سيتم استيرادها من هذه العقدة. تستورد العقد الحالية جميع الاتصالات افتراضيًا.",
       "allInbounds": "جميع الاتصالات الواردة",

+ 11 - 1
internal/web/translation/en-US.json

@@ -289,6 +289,7 @@
       "port": "Port",
       "portMap": "Port Mapping",
       "traffic": "Traffic",
+      "speed": "Speed",
       "details": "Details",
       "transportConfig": "Transport",
       "expireDate": "Duration",
@@ -648,6 +649,12 @@
     "clients": {
       "tabBasics": "Basics",
       "tabCredentials": "Credentials",
+      "tabLinks": "Links",
+      "linksHint": "Add third-party share links and remote subscription URLs to include in this client's subscription.",
+      "addExternalLink": "Add External Link",
+      "addExternalSubscription": "Add External Subscription",
+      "noExternalLinks": "No external links yet.",
+      "noExternalSubscriptions": "No external subscriptions yet.",
       "add": "Add Client",
       "edit": "Edit Client",
       "submitAdd": "Add Client",
@@ -880,6 +887,9 @@
       "regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
       "allowPrivateAddress": "Allow private address",
       "allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
+      "outboundTag": "Connection outbound",
+      "outboundTagHint": "Route this node's panel API traffic through the selected Xray outbound. A loopback bridge inbound is added to the running config automatically and applied live. Leave empty for a direct connection.",
+      "outboundTagPlaceholder": "Direct connection",
       "inboundSyncMode": "Inbound import",
       "inboundSyncModeHint": "Choose which inbounds are imported from this node. Existing nodes default to all inbounds.",
       "allInbounds": "All inbounds",
@@ -1765,4 +1775,4 @@
       "chooseInbound": "Choose an Inbound"
     }
   }
-}
+}

+ 10 - 0
internal/web/translation/es-ES.json

@@ -289,6 +289,7 @@
       "port": "Puerto",
       "portMap": "Asignación de puertos",
       "traffic": "Tráfico",
+      "speed": "Velocidad",
       "details": "Detalles",
       "transportConfig": "Transporte",
       "expireDate": "Fecha de Expiración",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "Básico",
       "tabCredentials": "Credenciales",
+      "tabLinks": "Enlaces",
+      "linksHint": "Añade enlaces de terceros y URLs de suscripción remotas para incluirlos en la suscripción de este cliente.",
+      "addExternalLink": "Añadir enlace externo",
+      "addExternalSubscription": "Añadir suscripción externa",
+      "noExternalLinks": "Aún no hay enlaces externos.",
+      "noExternalSubscriptions": "Aún no hay suscripciones externas.",
       "add": "Añadir cliente",
       "edit": "Editar cliente",
       "submitAdd": "Añadir cliente",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
       "allowPrivateAddress": "Permitir dirección privada",
       "allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
+      "outboundTag": "Outbound de conexión",
+      "outboundTagHint": "Enruta el tráfico de la API del panel de este nodo a través del outbound Xray seleccionado. Un inbound de puente loopback se agrega automáticamente a la configuración en ejecución y se aplica en vivo. Déjelo vacío para una conexión directa.",
+      "outboundTagPlaceholder": "Conexión directa",
       "inboundSyncMode": "Importación de inbounds",
       "inboundSyncModeHint": "Elige qué inbounds importar desde este nodo. Los nodos existentes importan todos de forma predeterminada.",
       "allInbounds": "Todos los inbounds",

+ 11 - 1
internal/web/translation/fa-IR.json

@@ -289,6 +289,7 @@
       "port": "پورت",
       "portMap": "نگاشت پورت",
       "traffic": "ترافیک",
+      "speed": "سرعت",
       "details": "توضیحات",
       "transportConfig": "انتقال",
       "expireDate": "مدت زمان",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "پایه",
       "tabCredentials": "اطلاعات اتصال",
+      "tabLinks": "لینک‌ها",
+      "linksHint": "لینک‌های اشتراک شخص‌ثالث و آدرس سابسکریپشن‌های خارجی را اضافه کنید تا در سابسکریپشن این کاربر قرار گیرند.",
+      "addExternalLink": "افزودن لینک خارجی",
+      "addExternalSubscription": "افزودن سابسکریپشن خارجی",
+      "noExternalLinks": "هنوز لینک خارجی‌ای اضافه نشده.",
+      "noExternalSubscriptions": "هنوز سابسکریپشن خارجی‌ای اضافه نشده.",
       "add": "افزودن کلاینت",
       "edit": "ویرایش کلاینت",
       "submitAdd": "افزودن کلاینت",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
       "allowPrivateAddress": "اجازه آدرس خصوصی",
       "allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
+      "outboundTag": "خروجی اتصال",
+      "outboundTagHint": "ترافیک API پنل این نود را از طریق خروجی Xray انتخاب‌شده مسیریابی کنید. یک inbound پل loopback به‌صورت خودکار به پیکربندی در حال اجرا اضافه شده و به‌صورت زنده اعمال می‌شود. برای اتصال مستقیم خالی بگذارید.",
+      "outboundTagPlaceholder": "اتصال مستقیم",
       "inboundSyncMode": "وارد کردن اینباندها",
       "inboundSyncModeHint": "اینباندهای قابل وارد کردن از این نود را انتخاب کنید. نودهای موجود به‌طور پیش‌فرض همه را وارد می‌کنند.",
       "allInbounds": "همه اینباندها",
@@ -1764,4 +1774,4 @@
       "chooseInbound": "یک ورودی انتخاب کنید"
     }
   }
-}
+}

+ 10 - 0
internal/web/translation/id-ID.json

@@ -289,6 +289,7 @@
       "port": "Port",
       "portMap": "Pemetaan port",
       "traffic": "Trafik",
+      "speed": "Kecepatan",
       "details": "Rincian",
       "transportConfig": "Transport",
       "expireDate": "Durasi",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "Dasar",
       "tabCredentials": "Kredensial",
+      "tabLinks": "Tautan",
+      "linksHint": "Tambahkan tautan berbagi pihak ketiga dan URL langganan jarak jauh untuk disertakan dalam langganan klien ini.",
+      "addExternalLink": "Tambah Tautan Eksternal",
+      "addExternalSubscription": "Tambah Langganan Eksternal",
+      "noExternalLinks": "Belum ada tautan eksternal.",
+      "noExternalSubscriptions": "Belum ada langganan eksternal.",
       "add": "Tambah klien",
       "edit": "Ubah klien",
       "submitAdd": "Tambah klien",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
       "allowPrivateAddress": "Izinkan alamat pribadi",
       "allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
+      "outboundTag": "Outbound koneksi",
+      "outboundTagHint": "Rutekan lalu lintas API panel node ini melalui outbound Xray yang dipilih. Sebuah inbound jembatan loopback ditambahkan secara otomatis ke konfigurasi yang berjalan dan diterapkan secara langsung. Biarkan kosong untuk koneksi langsung.",
+      "outboundTagPlaceholder": "Koneksi langsung",
       "inboundSyncMode": "Impor inbound",
       "inboundSyncModeHint": "Pilih inbound yang diimpor dari node ini. Node yang sudah ada mengimpor semua inbound secara default.",
       "allInbounds": "Semua inbound",

+ 10 - 0
internal/web/translation/ja-JP.json

@@ -289,6 +289,7 @@
       "port": "ポート",
       "portMap": "ポートマッピング",
       "traffic": "トラフィック",
+      "speed": "速度",
       "details": "詳細情報",
       "transportConfig": "トランスポート",
       "expireDate": "有効期限",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "基本",
       "tabCredentials": "認証情報",
+      "tabLinks": "リンク",
+      "linksHint": "サードパーティの共有リンクやリモートのサブスクリプションURLを追加して、このクライアントのサブスクリプションに含めます。",
+      "addExternalLink": "外部リンクを追加",
+      "addExternalSubscription": "外部サブスクリプションを追加",
+      "noExternalLinks": "外部リンクはまだありません。",
+      "noExternalSubscriptions": "外部サブスクリプションはまだありません。",
       "add": "クライアントを追加",
       "edit": "クライアントを編集",
       "submitAdd": "クライアントを追加",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
       "allowPrivateAddress": "プライベートアドレスを許可",
       "allowPrivateAddressHint": "プライベートネットワークまたはVPN上のノードにのみ有効にします。",
+      "outboundTag": "接続アウトバウンド",
+      "outboundTagHint": "選択した Xray アウトバウンドを経由して、このノードのパネル API トラフィックをルーティングします。ループバック ブリッジ inbound は実行中の設定に自動的に追加され、リアルタイムで適用されます。空のままにすると直接接続になります。",
+      "outboundTagPlaceholder": "直接接続",
       "inboundSyncMode": "インバウンドのインポート",
       "inboundSyncModeHint": "このノードからインポートするインバウンドを選択します。既存のノードは既定ですべてをインポートします。",
       "allInbounds": "すべてのインバウンド",

+ 10 - 0
internal/web/translation/pt-BR.json

@@ -289,6 +289,7 @@
       "port": "Porta",
       "portMap": "Mapeamento de portas",
       "traffic": "Tráfego",
+      "speed": "Velocidade",
       "details": "Detalhes",
       "transportConfig": "Transporte",
       "expireDate": "Duração",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "Básico",
       "tabCredentials": "Credenciais",
+      "tabLinks": "Links",
+      "linksHint": "Adicione links de terceiros e URLs de assinatura remotas para incluir na assinatura deste cliente.",
+      "addExternalLink": "Adicionar link externo",
+      "addExternalSubscription": "Adicionar assinatura externa",
+      "noExternalLinks": "Ainda não há links externos.",
+      "noExternalSubscriptions": "Ainda não há assinaturas externas.",
       "add": "Adicionar cliente",
       "edit": "Editar cliente",
       "submitAdd": "Adicionar cliente",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
       "allowPrivateAddress": "Permitir endereço privado",
       "allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
+      "outboundTag": "Outbound de conexão",
+      "outboundTagHint": "Roteie o tráfego da API do painel deste nó pelo outbound Xray selecionado. Um inbound de ponte loopback é adicionado automaticamente à configuração em execução e aplicado ao vivo. Deixe em branco para uma conexão direta.",
+      "outboundTagPlaceholder": "Conexão direta",
       "inboundSyncMode": "Importação de inbounds",
       "inboundSyncModeHint": "Escolha quais inbounds importar deste nó. Nós existentes importam todos por padrão.",
       "allInbounds": "Todos os inbounds",

+ 10 - 0
internal/web/translation/ru-RU.json

@@ -289,6 +289,7 @@
       "port": "Порт",
       "portMap": "Сопоставление портов",
       "traffic": "Трафик",
+      "speed": "Скорость",
       "details": "Подробнее",
       "transportConfig": "Транспорт",
       "expireDate": "Дата окончания",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "Основные",
       "tabCredentials": "Учетные данные",
+      "tabLinks": "Ссылки",
+      "linksHint": "Добавьте сторонние ссылки и URL удалённых подписок, чтобы включить их в подписку этого клиента.",
+      "addExternalLink": "Добавить внешнюю ссылку",
+      "addExternalSubscription": "Добавить внешнюю подписку",
+      "noExternalLinks": "Пока нет внешних ссылок.",
+      "noExternalSubscriptions": "Пока нет внешних подписок.",
       "add": "Добавить клиента",
       "edit": "Изменить клиента",
       "submitAdd": "Добавить клиента",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
       "allowPrivateAddress": "Разрешить частный адрес",
       "allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
+      "outboundTag": "Исходящее подключение",
+      "outboundTagHint": "Маршрутизируйте трафик API панели этого узла через выбранный исходящий Xray. Входящий мост обратной петли автоматически добавляется в текущую конфигурацию и применяется в реальном времени. Оставьте пустым для прямого подключения.",
+      "outboundTagPlaceholder": "Прямое подключение",
       "inboundSyncMode": "Импорт инбаундов",
       "inboundSyncModeHint": "Выберите, какие инбаунды импортировать с этой ноды. Для существующих нод по умолчанию импортируются все.",
       "allInbounds": "Все инбаунды",

+ 10 - 0
internal/web/translation/tr-TR.json

@@ -289,6 +289,7 @@
       "port": "Port",
       "portMap": "Port Eşlemesi",
       "traffic": "Trafik",
+      "speed": "Hız",
       "details": "Detaylar",
       "transportConfig": "Aktarım",
       "expireDate": "Süre",
@@ -648,6 +649,12 @@
     "clients": {
       "tabBasics": "Temel",
       "tabCredentials": "Kimlik Bilgileri",
+      "tabLinks": "Bağlantılar",
+      "linksHint": "Bu istemcinin aboneliğine dahil etmek için üçüncü taraf paylaşım bağlantıları ve uzak abonelik URL'leri ekleyin.",
+      "addExternalLink": "Harici Bağlantı Ekle",
+      "addExternalSubscription": "Harici Abonelik Ekle",
+      "noExternalLinks": "Henüz harici bağlantı yok.",
+      "noExternalSubscriptions": "Henüz harici abonelik yok.",
       "add": "Kullanıcı Ekle",
       "edit": "Kullanıcıyı Düzenle",
       "submitAdd": "Kullanıcı Ekle",
@@ -880,6 +887,9 @@
       "regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
       "allowPrivateAddress": "Özel Adrese İzin Ver",
       "allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştirin.",
+      "outboundTag": "Bağlantı gideni",
+      "outboundTagHint": "Bu düğümün panel API trafiğini seçilen Xray gideni üzerinden yönlendirin. Geri döngü köprüsü inbound'ı çalışan yapılandırmaya otomatik olarak eklenir ve canlı uygulanır. Doğrudan bağlantı için boş bırakın.",
+      "outboundTagPlaceholder": "Doğrudan bağlantı",
       "inboundSyncMode": "Inbound içe aktarma",
       "inboundSyncModeHint": "Bu düğümden içe aktarılacak inbound'ları seçin. Mevcut düğümler varsayılan olarak tümünü içe aktarır.",
       "allInbounds": "Tüm inbound'lar",

+ 10 - 0
internal/web/translation/uk-UA.json

@@ -289,6 +289,7 @@
       "port": "Порт",
       "portMap": "Відображення портів",
       "traffic": "Трафік",
+      "speed": "Швидкість",
       "details": "Деталі",
       "transportConfig": "Транспорт",
       "expireDate": "Тривалість",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "Основні",
       "tabCredentials": "Облікові дані",
+      "tabLinks": "Посилання",
+      "linksHint": "Додайте сторонні посилання та URL віддалених підписок, щоб включити їх до підписки цього клієнта.",
+      "addExternalLink": "Додати зовнішнє посилання",
+      "addExternalSubscription": "Додати зовнішню підписку",
+      "noExternalLinks": "Зовнішніх посилань ще немає.",
+      "noExternalSubscriptions": "Зовнішніх підписок ще немає.",
       "add": "Додати клієнта",
       "edit": "Редагувати клієнта",
       "submitAdd": "Додати клієнта",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
       "allowPrivateAddress": "Дозволити приватну адресу",
       "allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
+      "outboundTag": "Вихідне з'єднання",
+      "outboundTagHint": "Маршрутизуйте трафік API панелі цього вузла через вибраний вихідний Xray. Вхідний міст зворотної петлі автоматично додається до поточної конфігурації та застосовується в реальному часі. Залиште порожнім для прямого підключення.",
+      "outboundTagPlaceholder": "Пряме підключення",
       "inboundSyncMode": "Імпорт інбаундів",
       "inboundSyncModeHint": "Виберіть інбаунди для імпорту з цього вузла. Для наявних вузлів типово імпортуються всі.",
       "allInbounds": "Усі інбаунди",

+ 10 - 0
internal/web/translation/vi-VN.json

@@ -289,6 +289,7 @@
       "port": "Cổng",
       "portMap": "Ánh xạ cổng",
       "traffic": "Lưu lượng",
+      "speed": "Tốc độ",
       "details": "Chi tiết",
       "transportConfig": "Truyền dẫn",
       "expireDate": "Ngày hết hạn",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "Cơ bản",
       "tabCredentials": "Thông tin xác thực",
+      "tabLinks": "Liên kết",
+      "linksHint": "Thêm liên kết chia sẻ của bên thứ ba và URL đăng ký từ xa để đưa vào đăng ký của khách hàng này.",
+      "addExternalLink": "Thêm liên kết ngoài",
+      "addExternalSubscription": "Thêm đăng ký ngoài",
+      "noExternalLinks": "Chưa có liên kết ngoài.",
+      "noExternalSubscriptions": "Chưa có đăng ký ngoài.",
       "add": "Thêm khách hàng",
       "edit": "Chỉnh sửa khách hàng",
       "submitAdd": "Thêm khách hàng",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
       "allowPrivateAddress": "Cho phép địa chỉ riêng",
       "allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
+      "outboundTag": "Outbound kết nối",
+      "outboundTagHint": "Định tuyến lưu lượng API panel của node này qua outbound Xray đã chọn. Một inbound cầu nối loopback được tự động thêm vào cấu hình đang chạy và áp dụng trực tiếp. Để trống để kết nối trực tiếp.",
+      "outboundTagPlaceholder": "Kết nối trực tiếp",
       "inboundSyncMode": "Nhập inbound",
       "inboundSyncModeHint": "Chọn các inbound được nhập từ nút này. Các nút hiện có mặc định nhập tất cả.",
       "allInbounds": "Tất cả inbound",

+ 10 - 0
internal/web/translation/zh-CN.json

@@ -289,6 +289,7 @@
       "port": "端口",
       "portMap": "端口映射",
       "traffic": "流量",
+      "speed": "速度",
       "details": "详细信息",
       "transportConfig": "传输",
       "expireDate": "到期时间",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "基本",
       "tabCredentials": "凭据",
+      "tabLinks": "链接",
+      "linksHint": "添加第三方分享链接和远程订阅地址,将其包含在该客户端的订阅中。",
+      "addExternalLink": "添加外部链接",
+      "addExternalSubscription": "添加外部订阅",
+      "noExternalLinks": "暂无外部链接。",
+      "noExternalSubscriptions": "暂无外部订阅。",
       "add": "添加客户端",
       "edit": "编辑客户端",
       "submitAdd": "添加客户端",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
       "allowPrivateAddress": "允许私有地址",
       "allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
+      "outboundTag": "连接出站",
+      "outboundTagHint": "通过选定的 Xray 出站路由此节点的面板 API 流量。系统会自动将回环桥接入站添加到运行配置并实时应用。留空表示直接连接。",
+      "outboundTagPlaceholder": "直接连接",
       "inboundSyncMode": "入站导入",
       "inboundSyncModeHint": "选择要从此节点导入的入站。现有节点默认导入全部入站。",
       "allInbounds": "全部入站",

+ 10 - 0
internal/web/translation/zh-TW.json

@@ -289,6 +289,7 @@
       "port": "連接埠",
       "portMap": "連接埠對應",
       "traffic": "流量",
+      "speed": "速度",
       "details": "詳細資訊",
       "transportConfig": "傳輸",
       "expireDate": "到期時間",
@@ -647,6 +648,12 @@
     "clients": {
       "tabBasics": "基本",
       "tabCredentials": "認證資訊",
+      "tabLinks": "連結",
+      "linksHint": "新增第三方分享連結和遠端訂閱網址,將其包含在此用戶端的訂閱中。",
+      "addExternalLink": "新增外部連結",
+      "addExternalSubscription": "新增外部訂閱",
+      "noExternalLinks": "尚無外部連結。",
+      "noExternalSubscriptions": "尚無外部訂閱。",
       "add": "新增客戶端",
       "edit": "編輯客戶端",
       "submitAdd": "新增客戶端",
@@ -879,6 +886,9 @@
       "regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
       "allowPrivateAddress": "允許私有地址",
       "allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
+      "outboundTag": "連線出站",
+      "outboundTagHint": "透過選定的 Xray 出站路由此節點的面板 API 流量。系統會自動將迴環橋接入站加入執行中的設定並即時套用。留空表示直接連線。",
+      "outboundTagPlaceholder": "直接連線",
       "inboundSyncMode": "入站匯入",
       "inboundSyncModeHint": "選擇要從此節點匯入的入站。現有節點預設匯入所有入站。",
       "allInbounds": "所有入站",

+ 38 - 10
internal/web/web.go

@@ -252,6 +252,25 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	return engine, nil
 }
 
+// Background-job cadences. Centralized here as the single tuning surface; the
+// values are unchanged from the historical hardcoded cron specs. Follow-up:
+// make these configurable via settings, add per-tick jitter to de-synchronize
+// fleet load, skip expensive jobs when no WebSocket clients are connected or
+// node/xray state is unchanged, and export per-job duration/skipped/error
+// counters.
+const (
+	cadenceXrayRunning   = "@every 1s"
+	cadenceXrayRestart   = "@every 30s"
+	cadenceXrayTraffic   = "@every 5s"
+	cadenceMtproto       = "@every 10s"
+	cadenceClientIPScan  = "@every 10s"
+	cadenceNodeHeartbeat = "@every 5s"
+	cadenceNodeTraffic   = "@every 5s"
+	cadenceOutboundSub   = "@every 5m"
+	cadenceCheckHash     = "@every 2m"
+	cadenceCPUAlarm      = "@every 10s"
+)
+
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
 // jobs) which the panel relies on for periodic maintenance and monitoring.
 func (s *Server) startTask(restartXray bool) {
@@ -262,10 +281,10 @@ func (s *Server) startTask(restartXray bool) {
 		}
 	}
 	// Check whether xray is running every second
-	s.cron.AddJob("@every 1s", job.NewCheckXrayRunningJob())
+	s.cron.AddJob(cadenceXrayRunning, job.NewCheckXrayRunningJob())
 
 	// Check if xray needs to be restarted every 30 seconds
-	s.cron.AddFunc("@every 30s", func() {
+	s.cron.AddFunc(cadenceXrayRestart, func() {
 		if s.xrayService.IsNeedRestartAndSetFalse() {
 			err := s.xrayService.RestartXray(false)
 			if err != nil {
@@ -276,23 +295,23 @@ func (s *Server) startTask(restartXray bool) {
 
 	go func() {
 		time.Sleep(time.Second * 5)
-		s.cron.AddJob("@every 5s", job.NewXrayTrafficJob())
+		s.cron.AddJob(cadenceXrayTraffic, job.NewXrayTrafficJob())
 	}()
 
 	// Reconcile mtproto (mtg) sidecars and scrape their traffic
 	mtJob := job.NewMtprotoJob()
-	s.cron.AddJob("@every 10s", mtJob)
+	s.cron.AddJob(cadenceMtproto, mtJob)
 	go mtJob.Run()
 
 	// check client ips from log file every 10 sec
-	s.cron.AddJob("@every 10s", job.NewCheckClientIpJob())
+	s.cron.AddJob(cadenceClientIPScan, job.NewCheckClientIpJob())
 
-	s.cron.AddJob("@every 5s", job.NewNodeHeartbeatJob())
+	s.cron.AddJob(cadenceNodeHeartbeat, job.NewNodeHeartbeatJob())
 
-	s.cron.AddJob("@every 5s", job.NewNodeTrafficSyncJob())
+	s.cron.AddJob(cadenceNodeTraffic, job.NewNodeTrafficSyncJob())
 
 	// Outbound subscription auto-refresh (respects per-sub updateInterval)
-	s.cron.AddJob("@every 5m", job.NewOutboundSubscriptionJob())
+	s.cron.AddJob(cadenceOutboundSub, job.NewOutboundSubscriptionJob())
 
 	// check client ips from log file every day
 	s.cron.AddJob("@daily", job.NewClearLogsJob())
@@ -339,12 +358,12 @@ func (s *Server) startTask(restartXray bool) {
 		}
 
 		// check for Telegram bot callback query hash storage reset
-		s.cron.AddJob("@every 2m", job.NewCheckHashStorageJob())
+		s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob())
 
 		// Check CPU load and alarm to TgBot if threshold passes
 		cpuThreshold, err := s.settingService.GetTgCpu()
 		if (err == nil) && (cpuThreshold > 0) {
-			s.cron.AddJob("@every 10s", job.NewCheckCpuJob())
+			s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
 		}
 	} else {
 		s.cron.Remove(entry)
@@ -385,6 +404,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		APIPort:        func() int { return s.xrayService.GetXrayAPIPort() },
 		SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
 	}))
+	runtime.GetManager().SetNodeEgressResolver(&s.settingService)
 
 	engine, err := s.initRouter()
 	if err != nil {
@@ -407,6 +427,14 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 	if err != nil {
 		return err
 	}
+	if envPort, configured, envErr := config.GetPortOverride(); configured {
+		if envErr != nil {
+			logger.Warning("Ignoring invalid XUI_PORT; using configured web port:", port, envErr)
+		} else {
+			port = envPort
+			logger.Info("Using XUI_PORT override for web panel port:", port)
+		}
+	}
 	listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
 	listener, err := net.Listen("tcp", listenAddr)
 	if err != nil {

+ 1 - 1
internal/xray/client_traffic.go

@@ -4,7 +4,7 @@ package xray
 // It tracks upload/download usage, expiry times, and online status for inbound clients.
 type ClientTraffic struct {
 	Id         int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
-	InboundId  int    `json:"inboundId" form:"inboundId" example:"1"`
+	InboundId  int    `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"`
 	Enable     bool   `json:"enable" form:"enable" example:"true"`
 	Email      string `json:"email" form:"email" gorm:"unique" example:"user1"`
 	UUID       string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`