Просмотр исходного кода

feat: add support for subscription-based outbounds with auto-update (#5037)

* feat: add support for subscription-based outbounds with auto-update

- New OutboundSubscription model (full support on both SQLite and PostgreSQL)
- Go subscription link parser (vmess/vless/trojan/ss/hysteria2/wireguard) matching frontend behavior
- Stable tag assignment across refreshes (designed for balancer + routing use)
- Runtime merge of subscription outbounds into Xray config (additive only)
- Full CRUD + manual refresh + preview API
- Background auto-update job (per-subscription interval)
- Frontend management UI in Outbounds tab (Subscriptions drawer) + tag integration in balancers/routing rules
- Proper dual-database support including CLI migration path

Review & hardening notes:
- Fixed merge logic bug that could drop manual outbounds
- Added SSRF/private-IP protection on subscription URLs using SanitizePublicHTTPURL
- Improved update interval UX (hours + minutes)
- Auto-fetch on first subscription creation
- Added detailed comments on tag stability strategy and balancer implications when servers are added/removed/rotated
- Updated migrationModels() for CLI migrate-db support

* fix: resolve frontend lint/type errors and Go build break

Frontend (eslint + tsc clean):
- Destructure subscriptionOutboundTags prop in RoutingTab and
  BalancersTab. It was declared in the interface and used in useMemo
  but never destructured, so it resolved as an unresolved global
  (react-hooks warning + tsc "Cannot find name"). The prop is passed
  by XrayPage, so the feature was silently inert.
- OutboundsTab: remove unused useEffect import, add an OutboundSub
  type to replace any[] state and the any/any table render signature,
  type the subscriptionOutbounds cast, and replace unused catch (e)
  bindings with parameter-less catch. Also type HttpUtil.post as
  OutboundSub so r.obj?.id type-checks.

Backend (go build clean):
- outbound_subscription_job: websocket.MessageTypeXray is undefined;
  use the existing MessageTypeOutbounds since the job refreshes
  outbound subscriptions.

* fix(xray): make outbound subscription creation work end-to-end

- Correct API paths from /panel/xray/outbound-subs to
  /panel/api/xray/outbound-subs. The controller is mounted under
  /panel/api, so the old paths hit the SPA page route (GET-only)
  and 404'd on POST.
- Send the create-subscription body as a plain object instead of
  URLSearchParams. The axios request interceptor serializes bodies
  with qs.stringify, which can't read URLSearchParams' internal
  storage and produced an empty body, so the backend rejected it
  with "subscription URL is required".
- Use message.useMessage() + context holder instead of the static
  antd message API (resolves the "Static function can not consume
  context" warning), matching XrayPage's pattern.
- Migrate the subscriptions Drawer to antd v6 props: width -> size,
  destroyOnClose -> destroyOnHidden, and Space direction -> orientation.

* feat(xray): show traffic/test for subscription outbounds; harden + test the feature

Display (the reported issue):
- Replace the flat read-only pills with a proper read-only table (desktop)
  and cards (mobile) in a new SubscriptionOutbounds component, showing
  Address, Protocol, Traffic (matched by tag — already collected by Xray),
  and a Test button with Latency. No edit/delete/move (read-only).
- Test subscription outbounds via the existing /testOutbound endpoint, with
  results keyed by tag (subscriptionTestStates + testSubscriptionOutbound in
  useXraySetting, wired through XrayPage). Generalize isTesting/testResult to
  a string|number key so the same helpers serve index- and tag-keyed states.

i18n:
- Replace all hardcoded English subscription strings with t() calls and add
  pages.xray.outboundSub.* keys to en-US.json (other locales fall back).

Backend hardening + tests:
- xray.go: drop the tautological `subSvc != nil` check.
- outbound_subscription: re-validate every redirect hop against private/
  internal addresses (CheckRedirect) and cap the redirect chain, closing an
  SSRF gap where only the initial host was checked.
- Extract assignStableTags as a pure function and add unit tests for tag
  stability and SSRF rejection (the feature previously had no tests).

Misc:
- gofmt util/link/outbound.go (it was not gofmt-clean).

* fix(xray): make outbound-subs feature pass CI (test compile, route docs, openapi)

- outbound_test.go: remove unused `inner`/`lines` variables that broke the
  `util/link` test build (declared and not used).
- Document the 7 outbound-subscription routes in endpoints.ts (list, create,
  update, delete, del alias, refresh, parse) so TestAPIRoutesDocumented passes.
- Regenerate frontend/public/openapi.json (npm run gen) to include the new
  endpoints, satisfying the codegen freshness check.

* feat(xray): per-subscription allow-private, gap-filled tags, UI tweaks, delete refresh

Backend:
- Add a per-subscription AllowPrivate flag (default off). Create/Update/refresh
  and the redirect check sanitize the URL with it, so localhost/LAN sources work
  only when explicitly opted in; the SSRF guard still blocks private targets by
  default. Controller reads the allowPrivate form field on create/update/parse.
- Default outbound tag prefix now uses the smallest free "subN-" number instead
  of the auto-increment id, so deleting a subscription frees its number for reuse
  (a fresh start gives sub1) while staying stable per subscription. Extracted a
  pure defaultPrefixNumber() with unit tests.
- deleteOutboundSub now signals SetToNeedRestart so xray drops the outbounds.

Frontend:
- "Allow private address" toggle in the add form (sends allowPrivate).
- Delete now refreshes the xray view immediately (no manual page reload).
- Subscriptions manager opens as a centered Modal instead of a right-side Drawer.
- Move Outbounds to a top-level sidebar item under Nodes (out of Xray Configs).
- Collapse WARP/NordVPN into a "more" dropdown.
- Document the allowPrivate param in endpoints.ts.

* i18n(xray): translate outbound-subscription UI into all locales

- Translate the pages.xray.outboundSub.* strings (and allowPrivate label/hint)
  into all 12 non-English locales, matching each file's existing terminology.
- Remove the unused outboundSub.add ("Add subscription") key from every locale.

* feat(xray): subscription manager — edit, reorder/priority, status, preview, refresh-all

Backend:
- Per-subscription Priority + Prepend: subscriptions are ordered by Priority and
  placed before (Prepend) or after the manual template outbounds in the merge, so
  a subscription server can become the default. New Move(up/down) endpoint
  re-normalizes priorities; merge split into prepend/template/append.
- List now returns a derived OutboundCount and orders by priority, and strips the
  heavy LastFetchedOutbounds/LinkIdentities blobs from the list payload.
- Create/Update accept the prepend flag; new subs append at the end of priority.

Frontend (Outbound Subscriptions modal):
- Edit existing subscriptions (reuses the form + Update endpoint).
- Inline enable/disable Switch, Status column (OK / error tooltip), Outbounds
  count column, per-row refresh spinner, "Refresh all" button.
- Reorder (move up/down) controls + a "Before manual outbounds" toggle.
- Preview button: fetch+parse a URL via /parse without saving.
- Document the move route + prepend param in endpoints.ts; regenerate openapi.json.

* i18n(xray): translate new subscription-manager strings into all locales

Add the prepend/prependHint, preview/previewEmpty, refreshAll, statusOk and
toastUpdated keys to all 12 non-English locales, matching each file's terminology.

---------

Co-authored-by: MHSanaei <[email protected]>
Rouzbeh† 13 часов назад
Родитель
Сommit
0daedd3db9
37 измененных файлов с 3612 добавлено и 50 удалено
  1. 1 1
      CONTRIBUTING.md
  2. 1 0
      database/db.go
  3. 15 3
      database/migrate_data.go
  4. 22 0
      database/model/model.go
  5. 291 0
      frontend/public/openapi.json
  6. 62 30
      frontend/src/hooks/useXraySetting.ts
  7. 6 3
      frontend/src/layouts/AppSidebar.tsx
  8. 68 0
      frontend/src/pages/api-docs/endpoints.ts
  9. 15 0
      frontend/src/pages/xray/XrayPage.tsx
  10. 6 1
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  11. 14 0
      frontend/src/pages/xray/outbounds/OutboundsTab.css
  12. 421 5
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  13. 207 0
      frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx
  14. 2 2
      frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts
  15. 6 1
      frontend/src/pages/xray/routing/RoutingTab.tsx
  16. 5 0
      frontend/src/schemas/xray.ts
  17. 809 0
      util/link/outbound.go
  18. 62 0
      util/link/outbound_test.go
  19. 173 4
      web/controller/xray_setting.go
  20. 48 0
      web/job/outbound_subscription_job.go
  21. 540 0
      web/service/outbound_subscription.go
  22. 117 0
      web/service/outbound_subscription_test.go
  23. 42 0
      web/service/xray.go
  24. 52 0
      web/translation/ar-EG.json
  25. 52 0
      web/translation/en-US.json
  26. 52 0
      web/translation/es-ES.json
  27. 52 0
      web/translation/fa-IR.json
  28. 52 0
      web/translation/id-ID.json
  29. 52 0
      web/translation/ja-JP.json
  30. 52 0
      web/translation/pt-BR.json
  31. 52 0
      web/translation/ru-RU.json
  32. 52 0
      web/translation/tr-TR.json
  33. 52 0
      web/translation/uk-UA.json
  34. 52 0
      web/translation/vi-VN.json
  35. 52 0
      web/translation/zh-CN.json
  36. 52 0
      web/translation/zh-TW.json
  37. 3 0
      web/web.go

+ 1 - 1
CONTRIBUTING.md

@@ -162,7 +162,7 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
 | Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
 | Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
 | Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
 | Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
 
 
-The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/setting/*`, `/panel/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
+The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/api/setting/*`, `/panel/api/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
 
 
 > **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
 > **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
 
 

+ 1 - 0
database/db.go

@@ -73,6 +73,7 @@ func initModels() error {
 		&model.ClientGroup{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},
 		&model.NodeClientTraffic{},
+		&model.OutboundSubscription{},
 	}
 	}
 	for _, mdl := range models {
 	for _, mdl := range models {
 		if err := db.AutoMigrate(mdl); err != nil {
 		if err := db.AutoMigrate(mdl); err != nil {

+ 15 - 3
database/migrate_data.go

@@ -20,9 +20,20 @@ import (
 	"gorm.io/gorm/logger"
 	"gorm.io/gorm/logger"
 )
 )
 
 
-// migrationModels is the FK-aware order in which tables are created and copied.
-// Parents come before their children so foreign-key constraints stay satisfied
-// even when checks are not explicitly disabled.
+// migrationModels is the FK-aware order in which tables are created and copied
+// during `x-ui migrate-db --dsn` (SQLite → PostgreSQL data migration) and in
+// related tests.
+//
+// Important: When adding a new top-level model (like OutboundSubscription),
+// you must add it here **in addition to** the list in database/db.go:initModels().
+// This list is used for:
+//   - Creating the destination schema during cross-DB migration
+//   - Truncating tables
+//   - Copying data row-by-row
+//   - Resyncing Postgres sequences after bulk insert
+//
+// DumpSQLite / RestoreSQLite are schema-introspective (they read sqlite_master)
+// so they do not need manual updates.
 func migrationModels() []any {
 func migrationModels() []any {
 	return []any{
 	return []any{
 		&model.User{},
 		&model.User{},
@@ -39,6 +50,7 @@ func migrationModels() []any {
 		&model.ClientInbound{},
 		&model.ClientInbound{},
 		&model.InboundFallback{},
 		&model.InboundFallback{},
 		&model.NodeClientTraffic{},
 		&model.NodeClientTraffic{},
+		&model.OutboundSubscription{},
 	}
 	}
 }
 }
 
 

+ 22 - 0
database/model/model.go

@@ -705,6 +705,28 @@ type ClientMergeConflict struct {
 	Kept  any
 	Kept  any
 }
 }
 
 
+type OutboundSubscription struct {
+	Id                   int    `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
+	Remark               string `json:"remark" form:"remark"`
+	Url                  string `json:"url" form:"url"`
+	Enabled              bool   `json:"enabled" form:"enabled" gorm:"default:true"`
+	AllowPrivate         bool   `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
+	TagPrefix            string `json:"tagPrefix" form:"tagPrefix"`
+	UpdateInterval       int    `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
+	Priority             int    `json:"priority" form:"priority" gorm:"default:0"`              // order among subscriptions in the merged outbounds (lower = earlier)
+	Prepend              bool   `json:"prepend" form:"prepend" gorm:"default:false"`            // place this subscription's outbounds before the manual template outbounds
+	LastUpdated          int64  `json:"lastUpdated" form:"lastUpdated"`
+	LastError            string `json:"lastError" form:"lastError"`
+	LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"`
+	LinkIdentities       string `json:"-" gorm:"type:text;column:link_identities"`
+	CreatedAt            int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
+	UpdatedAt            int64  `json:"updatedAt" gorm:"autoUpdateTime:milli"`
+	// OutboundCount is a derived count of the last fetched outbounds (not
+	// persisted); List populates it so the UI can show how many outbounds a
+	// subscription produced without shipping the full payload.
+	OutboundCount int `json:"outboundCount" gorm:"-"`
+}
+
 func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
 func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
 	var conflicts []ClientMergeConflict
 	var conflicts []ClientMergeConflict
 	keep := func(field string, oldV, newV, kept any) {
 	keep := func(field string, oldV, newV, kept any) {

+ 291 - 0
frontend/public/openapi.json

@@ -7552,6 +7552,297 @@
         }
         }
       }
       }
     },
     },
+    "/panel/api/xray/outbound-subs": {
+      "get": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "List all outbound subscriptions (remote URLs that supply additional outbounds), newest first.",
+        "operationId": "get_panel_api_xray_outbound_subs",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      },
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.",
+        "operationId": "post_panel_api_xray_outbound_subs",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/{id}": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Update an existing outbound subscription by id. Accepts the same form fields as create.",
+        "operationId": "post_panel_api_xray_outbound_subs_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      },
+      "delete": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Delete an outbound subscription by id.",
+        "operationId": "delete_panel_api_xray_outbound_subs_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/{id}/del": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).",
+        "operationId": "post_panel_api_xray_outbound_subs_id_del",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/{id}/refresh": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload.",
+        "operationId": "post_panel_api_xray_outbound_subs_id_refresh",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/{id}/move": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Reorder a subscription one step up or down in priority (controls its position in the merged outbounds).",
+        "operationId": "post_panel_api_xray_outbound_subs_id_move",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Subscription id.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/outbound-subs/parse": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Preview a subscription URL: fetch and parse it into outbounds without persisting anything.",
+        "operationId": "post_panel_api_xray_outbound_subs_parse",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/{subPath}{subid}": {
     "/{subPath}{subid}": {
       "get": {
       "get": {
         "tags": [
         "tags": [

+ 62 - 30
frontend/src/hooks/useXraySetting.ts

@@ -51,9 +51,12 @@ export interface UseXraySettingResult {
   setOutboundTestUrl: (v: string) => void;
   setOutboundTestUrl: (v: string) => void;
   inboundTags: string[];
   inboundTags: string[];
   clientReverseTags: string[];
   clientReverseTags: string[];
+  subscriptionOutbounds: unknown[];
+  subscriptionOutboundTags: string[];
   restartResult: string;
   restartResult: string;
   outboundsTraffic: OutboundTrafficRow[];
   outboundsTraffic: OutboundTrafficRow[];
   outboundTestStates: Record<number, OutboundTestState>;
   outboundTestStates: Record<number, OutboundTestState>;
+  subscriptionTestStates: Record<string, OutboundTestState>;
   testingAll: boolean;
   testingAll: boolean;
   fetchAll: () => Promise<void>;
   fetchAll: () => Promise<void>;
   fetchOutboundsTraffic: () => Promise<void>;
   fetchOutboundsTraffic: () => Promise<void>;
@@ -63,6 +66,11 @@ export interface UseXraySettingResult {
     outbound: unknown,
     outbound: unknown,
     mode?: string,
     mode?: string,
   ) => Promise<OutboundTestResult | null>;
   ) => Promise<OutboundTestResult | null>;
+  testSubscriptionOutbound: (
+    tag: string,
+    outbound: unknown,
+    mode?: string,
+  ) => Promise<OutboundTestResult | null>;
   testAllOutbounds: (mode?: string) => Promise<void>;
   testAllOutbounds: (mode?: string) => Promise<void>;
   saveAll: () => Promise<void>;
   saveAll: () => Promise<void>;
   resetToDefault: () => Promise<void>;
   resetToDefault: () => Promise<void>;
@@ -118,8 +126,13 @@ export function useXraySetting(): UseXraySettingResult {
   const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
   const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
   const [inboundTags, setInboundTags] = useState<string[]>([]);
   const [inboundTags, setInboundTags] = useState<string[]>([]);
   const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
   const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
+  const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
+  const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
   const [restartResult, setRestartResult] = useState('');
   const [restartResult, setRestartResult] = useState('');
   const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
   const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
+  // Subscription outbounds aren't in templateSettings.outbounds, so their test
+  // results are keyed by tag rather than by index.
+  const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
   const [testingAll, setTestingAll] = useState(false);
   const [testingAll, setTestingAll] = useState(false);
 
 
   const oldXraySettingRef = useRef('');
   const oldXraySettingRef = useRef('');
@@ -146,6 +159,8 @@ export function useXraySetting(): UseXraySettingResult {
     syncingRef.current = false;
     syncingRef.current = false;
     setInboundTags(obj.inboundTags || []);
     setInboundTags(obj.inboundTags || []);
     setClientReverseTags(obj.clientReverseTags || []);
     setClientReverseTags(obj.clientReverseTags || []);
+    setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
+    setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
     const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
     const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
     setOutboundTestUrlState(nextUrl);
     setOutboundTestUrlState(nextUrl);
     oldOutboundTestUrlRef.current = nextUrl;
     oldOutboundTestUrlRef.current = nextUrl;
@@ -255,14 +270,10 @@ export function useXraySetting(): UseXraySettingResult {
 
 
   const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
   const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
 
 
-  const testOutbound = useCallback(
-    async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
-      if (!outbound) return null;
-      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
-      setOutboundTestStates((prev) => ({
-        ...prev,
-        [index]: { testing: true, result: null, mode: effMode },
-      }));
+  // Shared POST + parse for a single outbound test. Returns an OutboundTestResult
+  // (success or a failure-shaped result); callers store it under their own key.
+  const postOutboundTest = useCallback(
+    async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => {
       try {
       try {
         const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
         const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
           outbound: JSON.stringify(outbound),
           outbound: JSON.stringify(outbound),
@@ -270,34 +281,47 @@ export function useXraySetting(): UseXraySettingResult {
           mode: effMode,
           mode: effMode,
         });
         });
         const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
         const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
-        if (msg?.success && msg.obj) {
-          setOutboundTestStates((prev) => ({
-            ...prev,
-            [index]: { testing: false, result: msg.obj },
-          }));
-          return msg.obj;
-        }
-        setOutboundTestStates((prev) => ({
-          ...prev,
-          [index]: {
-            testing: false,
-            result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
-          },
-        }));
+        if (msg?.success && msg.obj) return msg.obj;
+        return { success: false, error: msg?.msg || 'Unknown error', mode: effMode };
       } catch (e) {
       } catch (e) {
-        setOutboundTestStates((prev) => ({
-          ...prev,
-          [index]: {
-            testing: false,
-            result: { success: false, error: String(e), mode: effMode },
-          },
-        }));
+        return { success: false, error: String(e), mode: effMode };
       }
       }
-      return null;
     },
     },
     [],
     [],
   );
   );
 
 
+  const testOutbound = useCallback(
+    async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
+      if (!outbound) return null;
+      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
+      setOutboundTestStates((prev) => ({
+        ...prev,
+        [index]: { testing: true, result: null, mode: effMode },
+      }));
+      const result = await postOutboundTest(outbound, effMode);
+      setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
+      return result.success ? result : null;
+    },
+    [postOutboundTest],
+  );
+
+  // Test a subscription outbound (not present in templateSettings.outbounds);
+  // results are keyed by tag in subscriptionTestStates.
+  const testSubscriptionOutbound = useCallback(
+    async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
+      if (!outbound || !tag) return null;
+      const effMode = isUdpOutbound(outbound) ? 'http' : mode;
+      setSubscriptionTestStates((prev) => ({
+        ...prev,
+        [tag]: { testing: true, result: null, mode: effMode },
+      }));
+      const result = await postOutboundTest(outbound, effMode);
+      setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
+      return result.success ? result : null;
+    },
+    [postOutboundTest],
+  );
+
   const testAllOutbounds = useCallback(async (mode = 'tcp') => {
   const testAllOutbounds = useCallback(async (mode = 'tcp') => {
     const list = templateSettingsRef.current?.outbounds || [];
     const list = templateSettingsRef.current?.outbounds || [];
     if (list.length === 0 || testingAll) return;
     if (list.length === 0 || testingAll) return;
@@ -358,14 +382,18 @@ export function useXraySetting(): UseXraySettingResult {
       setOutboundTestUrl,
       setOutboundTestUrl,
       inboundTags,
       inboundTags,
       clientReverseTags,
       clientReverseTags,
+      subscriptionOutbounds,
+      subscriptionOutboundTags,
       restartResult,
       restartResult,
       outboundsTraffic,
       outboundsTraffic,
       outboundTestStates,
       outboundTestStates,
+      subscriptionTestStates,
       testingAll,
       testingAll,
       fetchAll,
       fetchAll,
       fetchOutboundsTraffic: fetchOutboundsTrafficCb,
       fetchOutboundsTraffic: fetchOutboundsTrafficCb,
       resetOutboundsTraffic,
       resetOutboundsTraffic,
       testOutbound,
       testOutbound,
+      testSubscriptionOutbound,
       testAllOutbounds,
       testAllOutbounds,
       saveAll,
       saveAll,
       resetToDefault,
       resetToDefault,
@@ -384,14 +412,18 @@ export function useXraySetting(): UseXraySettingResult {
       setOutboundTestUrl,
       setOutboundTestUrl,
       inboundTags,
       inboundTags,
       clientReverseTags,
       clientReverseTags,
+      subscriptionOutbounds,
+      subscriptionOutboundTags,
       restartResult,
       restartResult,
       outboundsTraffic,
       outboundsTraffic,
       outboundTestStates,
       outboundTestStates,
+      subscriptionTestStates,
       testingAll,
       testingAll,
       fetchAll,
       fetchAll,
       fetchOutboundsTrafficCb,
       fetchOutboundsTrafficCb,
       resetOutboundsTraffic,
       resetOutboundsTraffic,
       testOutbound,
       testOutbound,
+      testSubscriptionOutbound,
       testAllOutbounds,
       testAllOutbounds,
       saveAll,
       saveAll,
       resetToDefault,
       resetToDefault,

+ 6 - 3
frontend/src/layouts/AppSidebar.tsx

@@ -40,7 +40,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
 const LOGOUT_KEY = '__logout__';
 
 
-type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
+type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound';
 
 
 const iconByName: Record<IconName, ComponentType> = {
 const iconByName: Record<IconName, ComponentType> = {
   dashboard: DashboardOutlined,
   dashboard: DashboardOutlined,
@@ -52,6 +52,7 @@ const iconByName: Record<IconName, ComponentType> = {
   cluster: ClusterOutlined,
   cluster: ClusterOutlined,
   logout: LogoutOutlined,
   logout: LogoutOutlined,
   apidocs: ApiOutlined,
   apidocs: ApiOutlined,
+  outbound: UploadOutlined,
 };
 };
 
 
 function readCollapsed(): boolean {
 function readCollapsed(): boolean {
@@ -137,6 +138,7 @@ export default function AppSidebar() {
     { key: '/clients', icon: 'team', title: t('menu.clients') },
     { key: '/clients', icon: 'team', title: t('menu.clients') },
     { key: '/groups', icon: 'groups', title: t('menu.groups') },
     { key: '/groups', icon: 'groups', title: t('menu.groups') },
     { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
     { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
+    { key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
     { key: '/settings', icon: 'setting', title: t('menu.settings') },
     { key: '/settings', icon: 'setting', title: t('menu.settings') },
     { key: '/xray', icon: 'tool', title: t('menu.xray') },
     { key: '/xray', icon: 'tool', title: t('menu.xray') },
     { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
     { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
@@ -162,7 +164,6 @@ export default function AppSidebar() {
   const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
   const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
     { key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
     { key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
     { key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
     { key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
-    { key: '/xray#outbound', icon: <UploadOutlined />, label: t('pages.xray.Outbounds') },
     { key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
     { key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
     { key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
     { key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
     { key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
     { key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
@@ -176,7 +177,9 @@ export default function AppSidebar() {
       ? `/xray${hash || '#basic'}`
       ? `/xray${hash || '#basic'}`
       : (pathname === '' ? '/' : pathname);
       : (pathname === '' ? '/' : pathname);
 
 
-  const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
+  // The Outbounds top-level item lives on /xray#outbound, so don't auto-open the
+  // Xray Configs submenu for it.
+  const openSubmenu = settingsActive ? '/settings' : xrayActive && hash !== '#outbound' ? '/xray' : null;
   const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
   const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
   useEffect(() => {
   useEffect(() => {
     if (openSubmenu) {
     if (openSubmenu) {

+ 68 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -1085,6 +1085,74 @@ export const sections: readonly Section[] = [
         ],
         ],
         body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
         body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
       },
       },
+      {
+        method: 'GET',
+        path: '/panel/api/xray/outbound-subs',
+        summary: 'List all outbound subscriptions (remote URLs that supply additional outbounds), newest first.',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs',
+        summary: 'Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.',
+        params: [
+          { name: 'remark', in: 'body (form)', type: 'string', desc: 'Optional display label.' },
+          { name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.' },
+          { name: 'tagPrefix', in: 'body (form)', type: 'string', desc: 'Prefix for generated outbound tags. Defaults to "sub<id>-".' },
+          { name: 'updateInterval', in: 'body (form)', type: 'integer', desc: 'Seconds between auto-refreshes. Default 600.' },
+          { name: 'enabled', in: 'body (form)', type: 'boolean', desc: 'Whether the subscription is active. Default true.' },
+          { name: 'allowPrivate', in: 'body (form)', type: 'boolean', desc: 'Allow the URL to point at a private/internal/loopback address (localhost/LAN). Default false (SSRF guard blocks private targets).' },
+          { name: 'prepend', in: 'body (form)', type: 'boolean', desc: 'Place this subscription\'s outbounds before the manual template outbounds (so one can become the default). Default false.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/:id',
+        summary: 'Update an existing outbound subscription by id. Accepts the same form fields as create.',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+        ],
+      },
+      {
+        method: 'DELETE',
+        path: '/panel/api/xray/outbound-subs/:id',
+        summary: 'Delete an outbound subscription by id.',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/:id/del',
+        summary: 'Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/:id/refresh',
+        summary: 'Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload.',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/:id/move',
+        summary: 'Reorder a subscription one step up or down in priority (controls its position in the merged outbounds).',
+        params: [
+          { name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
+          { name: 'dir', in: 'body (form)', type: 'string', desc: '"up" to raise priority, anything else to lower it.' },
+        ],
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/outbound-subs/parse',
+        summary: 'Preview a subscription URL: fetch and parse it into outbounds without persisting anything.',
+        params: [
+          { name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL to preview (required).' },
+        ],
+      },
     ],
     ],
   },
   },
 
 

+ 15 - 0
frontend/src/pages/xray/XrayPage.tsx

@@ -60,13 +60,17 @@ export default function XrayPage() {
     setOutboundTestUrl,
     setOutboundTestUrl,
     inboundTags,
     inboundTags,
     clientReverseTags,
     clientReverseTags,
+    subscriptionOutbounds,
+    subscriptionOutboundTags,
     restartResult,
     restartResult,
     outboundsTraffic,
     outboundsTraffic,
     outboundTestStates,
     outboundTestStates,
+    subscriptionTestStates,
     testingAll,
     testingAll,
     fetchAll,
     fetchAll,
     resetOutboundsTraffic,
     resetOutboundsTraffic,
     testOutbound,
     testOutbound,
+    testSubscriptionOutbound,
     testAllOutbounds,
     testAllOutbounds,
     saveAll,
     saveAll,
     resetToDefault,
     resetToDefault,
@@ -99,6 +103,11 @@ export default function XrayPage() {
     if (outbound) await testOutbound(idx, outbound, mode);
     if (outbound) await testOutbound(idx, outbound, mode);
   }
   }
 
 
+  async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
+    const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
+    if (tag) await testSubscriptionOutbound(tag, outbound, mode);
+  }
+
   function onAddOutbound(outbound: Record<string, unknown>) {
   function onAddOutbound(outbound: Record<string, unknown>) {
     mutate((tt) => {
     mutate((tt) => {
       if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
       if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
@@ -214,6 +223,7 @@ export default function XrayPage() {
             setTemplateSettings={setTemplateSettings}
             setTemplateSettings={setTemplateSettings}
             inboundTags={inboundTags}
             inboundTags={inboundTags}
             clientReverseTags={clientReverseTags}
             clientReverseTags={clientReverseTags}
+            subscriptionOutboundTags={subscriptionOutboundTags}
             isMobile={isMobile}
             isMobile={isMobile}
           />
           />
         );
         );
@@ -224,14 +234,18 @@ export default function XrayPage() {
             setTemplateSettings={setTemplateSettings}
             setTemplateSettings={setTemplateSettings}
             outboundsTraffic={outboundsTraffic}
             outboundsTraffic={outboundsTraffic}
             outboundTestStates={outboundTestStates}
             outboundTestStates={outboundTestStates}
+            subscriptionTestStates={subscriptionTestStates}
             testingAll={testingAll}
             testingAll={testingAll}
             inboundTags={inboundTags}
             inboundTags={inboundTags}
+            subscriptionOutbounds={subscriptionOutbounds}
             isMobile={isMobile}
             isMobile={isMobile}
             onResetTraffic={resetOutboundsTraffic}
             onResetTraffic={resetOutboundsTraffic}
             onTest={onTestOutbound}
             onTest={onTestOutbound}
+            onTestSubscription={onTestSubscription}
             onTestAll={testAllOutbounds}
             onTestAll={testAllOutbounds}
             onShowWarp={() => setWarpOpen(true)}
             onShowWarp={() => setWarpOpen(true)}
             onShowNord={() => setNordOpen(true)}
             onShowNord={() => setNordOpen(true)}
+            onRefreshXrayData={fetchAll}
           />
           />
         );
         );
       case 'balancer':
       case 'balancer':
@@ -240,6 +254,7 @@ export default function XrayPage() {
             templateSettings={templateSettings}
             templateSettings={templateSettings}
             setTemplateSettings={setTemplateSettings}
             setTemplateSettings={setTemplateSettings}
             clientReverseTags={clientReverseTags}
             clientReverseTags={clientReverseTags}
+            subscriptionOutboundTags={subscriptionOutboundTags}
             isMobile={isMobile}
             isMobile={isMobile}
           />
           />
         );
         );

+ 6 - 1
frontend/src/pages/xray/balancers/BalancersTab.tsx

@@ -18,6 +18,7 @@ interface BalancersTabProps {
   templateSettings: XraySettingsValue | null;
   templateSettings: XraySettingsValue | null;
   setTemplateSettings: SetTemplate;
   setTemplateSettings: SetTemplate;
   clientReverseTags: string[];
   clientReverseTags: string[];
+  subscriptionOutboundTags?: string[];
   isMobile: boolean;
   isMobile: boolean;
 }
 }
 
 
@@ -90,6 +91,7 @@ export default function BalancersTab({
   templateSettings,
   templateSettings,
   setTemplateSettings,
   setTemplateSettings,
   clientReverseTags,
   clientReverseTags,
+  subscriptionOutboundTags,
   isMobile,
   isMobile,
 }: BalancersTabProps) {
 }: BalancersTabProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -118,8 +120,11 @@ export default function BalancersTab({
     for (const tag of clientReverseTags || []) {
     for (const tag of clientReverseTags || []) {
       if (tag) tags.add(tag);
       if (tag) tags.add(tag);
     }
     }
+    for (const tag of subscriptionOutboundTags || []) {
+      if (tag) tags.add(tag);
+    }
     return [...tags];
     return [...tags];
-  }, [templateSettings?.outbounds, clientReverseTags]);
+  }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
 
 
   const otherTags = useMemo(() => {
   const otherTags = useMemo(() => {
     if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
     if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);

+ 14 - 0
frontend/src/pages/xray/outbounds/OutboundsTab.css

@@ -209,3 +209,17 @@
 .outbound-test-popover .dot-fail {
 .outbound-test-popover .dot-fail {
   color: #e04141;
   color: #e04141;
 }
 }
+
+.subscription-outbounds-head {
+  margin-bottom: 8px;
+}
+
+.subscription-outbounds-title {
+  font-weight: 600;
+  margin-bottom: 2px;
+}
+
+.subscription-outbounds-desc {
+  font-size: 12px;
+  opacity: 0.7;
+}

+ 421 - 5
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -3,22 +3,40 @@ import { useTranslation } from 'react-i18next';
 import {
 import {
   Button,
   Button,
   Col,
   Col,
+  Dropdown,
+  Form,
+  Input,
+  InputNumber,
   Modal,
   Modal,
   Popconfirm,
   Popconfirm,
   Radio,
   Radio,
   Row,
   Row,
   Space,
   Space,
+  Switch,
   Table,
   Table,
+  Tag,
   Tooltip,
   Tooltip,
+  message,
 } from 'antd';
 } from 'antd';
 import {
 import {
   PlusOutlined,
   PlusOutlined,
   CloudOutlined,
   CloudOutlined,
   ApiOutlined,
   ApiOutlined,
+  MoreOutlined,
   RetweetOutlined,
   RetweetOutlined,
   PlayCircleOutlined,
   PlayCircleOutlined,
+  ReloadOutlined,
+  DeleteOutlined,
+  EditOutlined,
+  EyeOutlined,
+  ArrowUpOutlined,
+  ArrowDownOutlined,
+  CheckCircleOutlined,
+  WarningOutlined,
 } from '@ant-design/icons';
 } from '@ant-design/icons';
 
 
+import { HttpUtil } from '@/utils';
+
 import OutboundFormModal from './OutboundFormModal';
 import OutboundFormModal from './OutboundFormModal';
 import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import './OutboundsTab.css';
 import './OutboundsTab.css';
@@ -26,20 +44,40 @@ import './OutboundsTab.css';
 import type { OutboundRow } from './outbounds-tab-types';
 import type { OutboundRow } from './outbounds-tab-types';
 import { useOutboundColumns } from './useOutboundColumns';
 import { useOutboundColumns } from './useOutboundColumns';
 import OutboundCardList from './OutboundCardList';
 import OutboundCardList from './OutboundCardList';
+import SubscriptionOutbounds from './SubscriptionOutbounds';
+
+interface OutboundSub {
+  id: number;
+  remark?: string;
+  url?: string;
+  enabled?: boolean;
+  allowPrivate?: boolean;
+  prepend?: boolean;
+  priority?: number;
+  tagPrefix?: string;
+  updateInterval?: number;
+  lastUpdated?: number;
+  lastError?: string;
+  outboundCount?: number;
+}
 
 
 interface OutboundsTabProps {
 interface OutboundsTabProps {
   templateSettings: XraySettingsValue | null;
   templateSettings: XraySettingsValue | null;
   setTemplateSettings: SetTemplate;
   setTemplateSettings: SetTemplate;
   outboundsTraffic: OutboundTrafficRow[];
   outboundsTraffic: OutboundTrafficRow[];
   outboundTestStates: Record<number, OutboundTestState>;
   outboundTestStates: Record<number, OutboundTestState>;
+  subscriptionTestStates: Record<string, OutboundTestState>;
   testingAll: boolean;
   testingAll: boolean;
   inboundTags: string[];
   inboundTags: string[];
+  subscriptionOutbounds?: unknown[];
   isMobile: boolean;
   isMobile: boolean;
   onResetTraffic: (tag: string) => void;
   onResetTraffic: (tag: string) => void;
   onTest: (index: number, mode: string) => void;
   onTest: (index: number, mode: string) => void;
+  onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
   onTestAll: (mode: string) => void;
   onTestAll: (mode: string) => void;
   onShowWarp: () => void;
   onShowWarp: () => void;
   onShowNord: () => void;
   onShowNord: () => void;
+  onRefreshXrayData?: () => void;
 }
 }
 
 
 export default function OutboundsTab({
 export default function OutboundsTab({
@@ -47,23 +85,49 @@ export default function OutboundsTab({
   setTemplateSettings,
   setTemplateSettings,
   outboundsTraffic,
   outboundsTraffic,
   outboundTestStates,
   outboundTestStates,
+  subscriptionTestStates,
   testingAll,
   testingAll,
   inboundTags: _inboundTags,
   inboundTags: _inboundTags,
+  subscriptionOutbounds,
   isMobile,
   isMobile,
   onResetTraffic,
   onResetTraffic,
   onTest,
   onTest,
+  onTestSubscription,
   onTestAll,
   onTestAll,
   onShowWarp,
   onShowWarp,
   onShowNord,
   onShowNord,
+  onRefreshXrayData,
 }: OutboundsTabProps) {
 }: OutboundsTabProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [modal, modalContextHolder] = Modal.useModal();
   const [modal, modalContextHolder] = Modal.useModal();
+  const [messageApi, messageContextHolder] = message.useMessage();
   const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
   const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
   const [modalOpen, setModalOpen] = useState(false);
   const [modalOpen, setModalOpen] = useState(false);
   const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
   const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
   const [editingIndex, setEditingIndex] = useState<number | null>(null);
   const [editingIndex, setEditingIndex] = useState<number | null>(null);
   const [existingTags, setExistingTags] = useState<string[]>([]);
   const [existingTags, setExistingTags] = useState<string[]>([]);
 
 
+  // Subscription manager (CRUD + reorder + refresh + preview)
+  const [subDrawerOpen, setSubDrawerOpen] = useState(false);
+  const [subs, setSubs] = useState<OutboundSub[]>([]);
+  const [subsLoading, setSubsLoading] = useState(false);
+  const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
+  const [editingSubId, setEditingSubId] = useState<number | null>(null);
+  const [savingSub, setSavingSub] = useState(false);
+  const [refreshingId, setRefreshingId] = useState<number | null>(null);
+  const [refreshingAll, setRefreshingAll] = useState(false);
+  const [busyId, setBusyId] = useState<number | null>(null);
+  const [previewing, setPreviewing] = useState(false);
+  const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null);
+
+  // Convenience: expose hours/minutes for the interval input
+  const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
+  const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60);
+  function setIntervalHM(h: number, m: number) {
+    const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60);
+    setNewSub((prev) => ({ ...prev, updateInterval: secs }));
+  }
+
   const outbounds = useMemo(
   const outbounds = useMemo(
     () => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
     () => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
     [templateSettings?.outbounds],
     [templateSettings?.outbounds],
@@ -89,6 +153,11 @@ export default function OutboundsTab({
     setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
     setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
     setModalOpen(true);
     setModalOpen(true);
   }
   }
+
+  function openSubManager() {
+    setSubDrawerOpen(true);
+    loadSubs();
+  }
   function openEdit(idx: number) {
   function openEdit(idx: number) {
     setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
     setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
     setEditingIndex(idx);
     setEditingIndex(idx);
@@ -147,6 +216,169 @@ export default function OutboundsTab({
     });
     });
   }
   }
 
 
+  // --- Subscription management (minimal inline UI) ---
+  async function loadSubs() {
+    setSubsLoading(true);
+    try {
+      const r = await HttpUtil.get('/panel/api/xray/outbound-subs');
+      if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []);
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastLoadFailed'));
+    } finally {
+      setSubsLoading(false);
+    }
+  }
+  function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean }) {
+    return {
+      remark: src.remark ?? '',
+      url: src.url ?? '',
+      tagPrefix: src.tagPrefix ?? '',
+      updateInterval: src.updateInterval ?? 600,
+      enabled: src.enabled ?? true,
+      allowPrivate: src.allowPrivate ?? false,
+      prepend: src.prepend ?? false,
+    };
+  }
+  function resetSubForm() {
+    setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
+    setEditingSubId(null);
+    setPreviewData(null);
+  }
+  function openEditSub(sub: OutboundSub) {
+    setNewSub({
+      remark: sub.remark ?? '',
+      url: sub.url ?? '',
+      tagPrefix: sub.tagPrefix ?? '',
+      updateInterval: sub.updateInterval ?? 600,
+      enabled: sub.enabled ?? true,
+      allowPrivate: sub.allowPrivate ?? false,
+      prepend: sub.prepend ?? false,
+    });
+    setEditingSubId(sub.id);
+    setPreviewData(null);
+  }
+  async function saveSub() {
+    if (!newSub.url.trim()) {
+      messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
+      return;
+    }
+    setSavingSub(true);
+    try {
+      const url = editingSubId != null
+        ? `/panel/api/xray/outbound-subs/${editingSubId}`
+        : '/panel/api/xray/outbound-subs';
+      const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
+      if (r?.success) {
+        messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded'));
+        const createdId = editingSubId == null ? r.obj?.id : undefined;
+        resetSubForm();
+        await loadSubs();
+        if (createdId) await refreshOne(createdId);
+        onRefreshXrayData?.();
+      } else {
+        messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
+    } finally {
+      setSavingSub(false);
+    }
+  }
+  async function previewSub() {
+    if (!newSub.url.trim()) {
+      messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
+      return;
+    }
+    setPreviewing(true);
+    setPreviewData(null);
+    try {
+      const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>('/panel/api/xray/outbound-subs/parse', { url: newSub.url, allowPrivate: newSub.allowPrivate });
+      if (r?.success && Array.isArray(r.obj)) {
+        setPreviewData(r.obj);
+        if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
+      } else {
+        messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty'));
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.previewEmpty'));
+    } finally {
+      setPreviewing(false);
+    }
+  }
+  async function toggleEnabled(sub: OutboundSub) {
+    setBusyId(sub.id);
+    try {
+      const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${sub.id}`, subBody({ ...sub, enabled: !sub.enabled }));
+      if (r?.success) {
+        await loadSubs();
+        onRefreshXrayData?.();
+      } else {
+        messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
+    } finally {
+      setBusyId(null);
+    }
+  }
+  async function moveSub(id: number, dir: 'up' | 'down') {
+    setBusyId(id);
+    try {
+      const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir });
+      if (r?.success) {
+        await loadSubs();
+        onRefreshXrayData?.();
+      }
+    } catch {
+      /* ignore */
+    } finally {
+      setBusyId(null);
+    }
+  }
+  async function refreshOne(id: number) {
+    setRefreshingId(id);
+    try {
+      const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`);
+      if (r?.success) {
+        messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
+        await loadSubs();
+        onRefreshXrayData?.();
+      } else {
+        messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed'));
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed'));
+    } finally {
+      setRefreshingId(null);
+    }
+  }
+  async function refreshAllSubs() {
+    if (subs.length === 0) return;
+    setRefreshingAll(true);
+    try {
+      for (const s of subs) {
+        try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ }
+      }
+      messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
+      await loadSubs();
+      onRefreshXrayData?.();
+    } finally {
+      setRefreshingAll(false);
+    }
+  }
+  async function deleteOne(id: number) {
+    try {
+      const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`);
+      if (r?.success) {
+        messageApi.success(t('pages.xray.outboundSub.toastDeleted'));
+        await loadSubs();
+        onRefreshXrayData?.();
+      }
+    } catch {
+      messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed'));
+    }
+  }
+
   const columns = useOutboundColumns({
   const columns = useOutboundColumns({
     testMode,
     testMode,
     rows,
     rows,
@@ -164,6 +396,7 @@ export default function OutboundsTab({
   return (
   return (
     <>
     <>
       {modalContextHolder}
       {modalContextHolder}
+      {messageContextHolder}
       <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
       <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
         <Row gutter={[12, 12]} align="middle" justify="space-between">
         <Row gutter={[12, 12]} align="middle" justify="space-between">
           <Col xs={24} sm={12}>
           <Col xs={24} sm={12}>
@@ -171,12 +404,20 @@ export default function OutboundsTab({
               <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
               <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
                 {!isMobile && t('pages.xray.Outbounds')}
                 {!isMobile && t('pages.xray.Outbounds')}
               </Button>
               </Button>
-              <Button type="primary" icon={<CloudOutlined />} onClick={onShowWarp}>
-                WARP
-              </Button>
-              <Button type="primary" icon={<ApiOutlined />} onClick={onShowNord}>
-                NordVPN
+              <Button icon={<CloudOutlined />} onClick={openSubManager}>
+                {t('pages.xray.outboundSub.manage')}
               </Button>
               </Button>
+              <Dropdown
+                trigger={['click']}
+                menu={{
+                  items: [
+                    { key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
+                    { key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
+                  ],
+                }}
+              >
+                <Button icon={<MoreOutlined />}>{t('more')}</Button>
+              </Dropdown>
             </Space>
             </Space>
           </Col>
           </Col>
           <Col xs={24} sm={12} className="toolbar-right">
           <Col xs={24} sm={12} className="toolbar-right">
@@ -232,7 +473,182 @@ export default function OutboundsTab({
           onClose={() => setModalOpen(false)}
           onClose={() => setModalOpen(false)}
           onConfirm={onConfirm}
           onConfirm={onConfirm}
         />
         />
+
+        {/* Subscription outbounds (read-only, merged at runtime) */}
+        {Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
+          <SubscriptionOutbounds
+            subscriptionOutbounds={subscriptionOutbounds}
+            outboundsTraffic={outboundsTraffic}
+            subscriptionTestStates={subscriptionTestStates}
+            testMode={testMode}
+            isMobile={isMobile}
+            onTestSubscription={onTestSubscription}
+          />
+        )}
       </Space>
       </Space>
+
+      <Modal
+        title={t('pages.xray.outboundSub.title')}
+        open={subDrawerOpen}
+        onCancel={() => setSubDrawerOpen(false)}
+        footer={null}
+        width={isMobile ? '100%' : 640}
+        destroyOnHidden
+      >
+        <Space orientation="vertical" style={{ width: '100%' }} size="large">
+          <div>
+            {editingSubId != null && (
+              <div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
+                <Tag color="blue">{t('edit')}</Tag>
+                <span style={{ fontWeight: 600 }}>{newSub.remark || newSub.url}</span>
+              </div>
+            )}
+            <Form layout="vertical" size="small">
+              <Form.Item label={t('pages.xray.outboundSub.remark')}>
+                <Input value={newSub.remark} onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} />
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.url')} required>
+                <Input value={newSub.url} onChange={(e) => setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} />
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
+                <Input value={newSub.tagPrefix} onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} />
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.interval')}>
+                <Space>
+                  <InputNumber
+                    min={0}
+                    value={intervalHours}
+                    onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
+                    style={{ width: 80 }}
+                  /> {t('pages.xray.outboundSub.hours')}
+                  <InputNumber
+                    min={0}
+                    max={59}
+                    value={intervalMinutes}
+                    onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
+                    style={{ width: 80 }}
+                  /> {t('pages.xray.outboundSub.minutes')}
+                </Space>
+                <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
+                  {t('pages.xray.outboundSub.intervalHint')}
+                </div>
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.enabled')}>
+                <Switch checked={newSub.enabled} onChange={(v) => setNewSub({ ...newSub, enabled: v })} />
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
+                <Switch checked={newSub.allowPrivate} onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })} />
+                <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
+                  {t('pages.xray.outboundSub.allowPrivateHint')}
+                </div>
+              </Form.Item>
+              <Form.Item label={t('pages.xray.outboundSub.prepend')}>
+                <Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
+                <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
+                  {t('pages.xray.outboundSub.prependHint')}
+                </div>
+              </Form.Item>
+              <Space wrap>
+                <Button type="primary" onClick={saveSub} loading={savingSub} icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}>
+                  {editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
+                </Button>
+                <Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
+                  {t('pages.xray.outboundSub.preview')}
+                </Button>
+                {editingSubId != null && <Button onClick={resetSubForm}>{t('cancel')}</Button>}
+              </Space>
+              {previewData && previewData.length > 0 && (
+                <div style={{ marginTop: 8 }}>
+                  <div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>{previewData.length} · {t('pages.xray.Outbounds')}</div>
+                  <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 120, overflow: 'auto' }}>
+                    {previewData.map((o, i) => (
+                      <Tag key={i}>{o?.tag || '—'}{o?.protocol ? ` · ${o.protocol}` : ''}</Tag>
+                    ))}
+                  </div>
+                </div>
+              )}
+            </Form>
+          </div>
+
+          <div>
+            <div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
+              {t('pages.xray.outboundSub.active')}
+              <Button size="small" icon={<ReloadOutlined />} onClick={loadSubs} loading={subsLoading} />
+              {subs.length > 0 && (
+                <Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}>
+                  {t('pages.xray.outboundSub.refreshAll')}
+                </Button>
+              )}
+            </div>
+            {subs.length === 0 ? (
+              <div style={{ color: '#888' }}>{t('pages.xray.outboundSub.empty')}</div>
+            ) : (
+              <Table
+                size="small"
+                dataSource={subs}
+                rowKey={(r) => r.id}
+                pagination={false}
+                scroll={{ x: true }}
+                columns={[
+                  {
+                    title: '',
+                    key: 'order',
+                    width: 56,
+                    render: (_: unknown, r: OutboundSub, index: number) => (
+                      <Space size={0}>
+                        <Button type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} />
+                        <Button type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
+                      </Space>
+                    ),
+                  },
+                  {
+                    title: t('pages.xray.outboundSub.colRemark'),
+                    key: 'remark',
+                    render: (_: unknown, r: OutboundSub) => (
+                      <div>
+                        <div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
+                        {r.tagPrefix && <div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>}
+                      </div>
+                    ),
+                  },
+                  { title: t('pages.xray.Outbounds'), dataIndex: 'outboundCount', key: 'outboundCount', align: 'center', render: (v) => v ?? 0 },
+                  {
+                    title: t('status'),
+                    key: 'status',
+                    align: 'center',
+                    render: (_: unknown, r: OutboundSub) => (r.lastError
+                      ? <Tooltip title={r.lastError}><WarningOutlined style={{ color: '#e04141' }} /></Tooltip>
+                      : <Tooltip title={t('pages.xray.outboundSub.statusOk')}><CheckCircleOutlined style={{ color: '#008771' }} /></Tooltip>),
+                  },
+                  { title: t('pages.xray.outboundSub.colLastFetch'), dataIndex: 'lastUpdated', key: 'lastUpdated', render: (v: number) => v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never') },
+                  {
+                    title: t('pages.xray.outboundSub.colEnabled'),
+                    key: 'enabled',
+                    align: 'center',
+                    render: (_: unknown, r: OutboundSub) => <Switch size="small" checked={!!r.enabled} loading={busyId === r.id} onChange={() => toggleEnabled(r)} />,
+                  },
+                  {
+                    title: '',
+                    key: 'actions',
+                    render: (_: unknown, r: OutboundSub) => (
+                      <Space>
+                        <Button size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} />
+                        <Button size="small" icon={<ReloadOutlined />} loading={refreshingId === r.id} onClick={() => refreshOne(r.id)} title={t('pages.xray.outboundSub.refreshNow')} />
+                        <Popconfirm title={t('pages.xray.outboundSub.deleteConfirm')} okText={t('delete')} cancelText={t('cancel')} onConfirm={() => deleteOne(r.id)}>
+                          <Button size="small" danger icon={<DeleteOutlined />} />
+                        </Popconfirm>
+                      </Space>
+                    ),
+                  },
+                ]}
+              />
+            )}
+            <div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
+              {t('pages.xray.outboundSub.restartHint')}
+            </div>
+          </div>
+        </Space>
+      </Modal>
     </>
     </>
   );
   );
 }
 }

+ 207 - 0
frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx

@@ -0,0 +1,207 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Popover, Table, Tag, Tooltip } from 'antd';
+import {
+  ThunderboltOutlined,
+  CheckCircleFilled,
+  CloseCircleFilled,
+  LoadingOutlined,
+} from '@ant-design/icons';
+import type { ColumnsType } from 'antd/es/table';
+
+import { SizeFormatter } from '@/utils';
+import { OutboundProtocols as Protocols } from '@/schemas/primitives';
+import { isUdpOutbound } from '@/hooks/useXraySetting';
+import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
+
+import type { OutboundRow } from './outbounds-tab-types';
+import {
+  hasBreakdown,
+  isTesting,
+  isUntestable,
+  outboundAddresses,
+  showSecurity,
+  testResult,
+  trafficFor,
+} from './outbounds-tab-helpers';
+
+interface SubscriptionOutboundsProps {
+  subscriptionOutbounds: unknown[];
+  outboundsTraffic: OutboundTrafficRow[];
+  subscriptionTestStates: Record<string, OutboundTestState>;
+  testMode: 'tcp' | 'http';
+  isMobile: boolean;
+  onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
+}
+
+// Read-only view of outbounds imported from active subscriptions. They are not
+// part of the editable template (so no edit/delete/move), but traffic is matched
+// by tag and they can be latency-tested via the same backend endpoint.
+export default function SubscriptionOutbounds({
+  subscriptionOutbounds,
+  outboundsTraffic,
+  subscriptionTestStates,
+  testMode,
+  isMobile,
+  onTestSubscription,
+}: SubscriptionOutboundsProps) {
+  const { t } = useTranslation();
+
+  const rows = useMemo<OutboundRow[]>(
+    () => (subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
+    [subscriptionOutbounds],
+  );
+
+  if (rows.length === 0) return null;
+
+  const identityCell = (record: OutboundRow) => (
+    <div className="identity-cell">
+      <Tooltip title={record.tag}>
+        <span className="tag-name">{record.tag || '—'}</span>
+      </Tooltip>
+      <div className="protocol-line">
+        <Tag color="green">{record.protocol}</Tag>
+        {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
+          <>
+            <Tag>{record.streamSettings?.network}</Tag>
+            {showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
+          </>
+        )}
+      </div>
+    </div>
+  );
+
+  const addressCell = (record: OutboundRow) => {
+    const addrs = outboundAddresses(record);
+    return (
+      <div className="address-list">
+        {addrs.length === 0 ? (
+          <span className="empty">—</span>
+        ) : (
+          addrs.map((addr) => (
+            <Tooltip key={addr} title={addr}>
+              <span className="address-pill">{addr}</span>
+            </Tooltip>
+          ))
+        )}
+      </div>
+    );
+  };
+
+  const trafficCell = (record: OutboundRow) => {
+    const tr = trafficFor(outboundsTraffic, record);
+    return (
+      <>
+        <span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
+        <span className="traffic-sep" />
+        <span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
+      </>
+    );
+  };
+
+  const latencyCell = (record: OutboundRow) => {
+    const key = record.tag || '';
+    const r = testResult(subscriptionTestStates, key);
+    if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
+    return (
+      <Popover
+        placement="topLeft"
+        rootClassName="outbound-test-popover"
+        content={
+          <div className="timing-breakdown">
+            <div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
+              {r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
+              {r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
+            </div>
+            {hasBreakdown(r) && (
+              <>
+                {(r.endpoints || []).map((ep) => (
+                  <div key={ep.address} className="endpoint-row">
+                    <span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
+                    <span className="ep-addr">{ep.address}</span>
+                    <span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
+                  </div>
+                ))}
+              </>
+            )}
+          </div>
+        }
+      >
+        <span className={r.success ? 'pill-ok' : 'pill-fail'}>
+          {r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
+          {r.success ? <span>{r.delay}&nbsp;ms</span> : <span>failed</span>}
+        </span>
+      </Popover>
+    );
+  };
+
+  const testButton = (record: OutboundRow) => {
+    const key = record.tag || '';
+    return (
+      <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
+        <Button
+          type="primary"
+          shape="circle"
+          size={isMobile ? 'small' : undefined}
+          loading={isTesting(subscriptionTestStates, key)}
+          disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
+          icon={<ThunderboltOutlined />}
+          onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
+        />
+      </Tooltip>
+    );
+  };
+
+  const header = (
+    <div className="subscription-outbounds-head">
+      <div className="subscription-outbounds-title">{t('pages.xray.outboundSub.fromSubsTitle')}</div>
+      <div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
+    </div>
+  );
+
+  if (isMobile) {
+    return (
+      <div className="subscription-outbounds" style={{ marginTop: 16 }}>
+        {header}
+        {rows.map((record, index) => (
+          <div key={record.key} className="outbound-card">
+            <div className="card-head">
+              <div className="card-identity">
+                <span className="card-num">{index + 1}</span>
+                {identityCell(record)}
+              </div>
+              {testButton(record)}
+            </div>
+            {outboundAddresses(record).length > 0 && addressCell(record)}
+            <div className="card-foot">
+              {trafficCell(record)}
+              <span className="card-test">{latencyCell(record)}</span>
+            </div>
+          </div>
+        ))}
+      </div>
+    );
+  }
+
+  const columns: ColumnsType<OutboundRow> = [
+    {
+      title: '#',
+      key: 'num',
+      align: 'center',
+      width: 60,
+      render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
+    },
+    { title: t('pages.xray.outbound.tag'), key: 'identity', align: 'left', render: (_v, record) => identityCell(record) },
+    { title: t('pages.inbounds.address'), key: 'address', align: 'left', render: (_v, record) => addressCell(record) },
+    { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200, render: (_v, record) => trafficCell(record) },
+    { title: t('pages.nodes.latency'), key: 'testResult', align: 'left', width: 140, render: (_v, record) => latencyCell(record) },
+    { title: t('check'), key: 'test', align: 'center', width: 80, render: (_v, record) => testButton(record) },
+  ];
+
+  return (
+    <div className="subscription-outbounds" style={{ marginTop: 16 }}>
+      {header}
+      <Table columns={columns} dataSource={rows} rowKey={(r) => r.key} pagination={false} size="small" />
+    </div>
+  );
+}

+ 2 - 2
frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts

@@ -53,10 +53,10 @@ export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRo
   return { up: tr?.up || 0, down: tr?.down || 0 };
   return { up: tr?.up || 0, down: tr?.down || 0 };
 }
 }
 
 
-export function isTesting(states: Record<number, OutboundTestState>, idx: number): boolean {
+export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
   return !!states?.[idx]?.testing;
   return !!states?.[idx]?.testing;
 }
 }
 
 
-export function testResult(states: Record<number, OutboundTestState>, idx: number) {
+export function testResult<K extends string | number>(states: Record<K, OutboundTestState>, idx: K) {
   return states?.[idx]?.result || null;
   return states?.[idx]?.result || null;
 }
 }

+ 6 - 1
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -20,6 +20,7 @@ interface RoutingTabProps {
   setTemplateSettings: SetTemplate;
   setTemplateSettings: SetTemplate;
   inboundTags: string[];
   inboundTags: string[];
   clientReverseTags: string[];
   clientReverseTags: string[];
+  subscriptionOutboundTags?: string[];
   isMobile: boolean;
   isMobile: boolean;
 }
 }
 
 
@@ -28,6 +29,7 @@ export default function RoutingTab({
   setTemplateSettings,
   setTemplateSettings,
   inboundTags,
   inboundTags,
   clientReverseTags,
   clientReverseTags,
+  subscriptionOutboundTags,
   isMobile,
   isMobile,
 }: RoutingTabProps) {
 }: RoutingTabProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -116,8 +118,11 @@ export default function RoutingTab({
     for (const tag of clientReverseTags || []) {
     for (const tag of clientReverseTags || []) {
       if (tag) out.add(tag);
       if (tag) out.add(tag);
     }
     }
+    for (const tag of subscriptionOutboundTags || []) {
+      if (tag) out.add(tag);
+    }
     return [...out];
     return [...out];
-  }, [templateSettings?.outbounds, clientReverseTags]);
+  }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
 
 
   const balancerTagOptions = useMemo(() => {
   const balancerTagOptions = useMemo(() => {
     const out: string[] = [''];
     const out: string[] = [''];

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

@@ -40,6 +40,11 @@ export const XrayConfigPayloadSchema = z.object({
   inboundTags: z.array(z.string()).optional(),
   inboundTags: z.array(z.string()).optional(),
   clientReverseTags: z.array(z.string()).optional(),
   clientReverseTags: z.array(z.string()).optional(),
   outboundTestUrl: z.string().optional(),
   outboundTestUrl: z.string().optional(),
+  // Subscription outbounds are injected at runtime (not persisted in xraySetting).
+  // They are provided here so the UI can display them and use their tags in
+  // balancers / routing rules.
+  subscriptionOutbounds: z.array(z.unknown()).optional(),
+  subscriptionOutboundTags: z.array(z.string()).optional(),
 }).loose();
 }).loose();
 
 
 export const OutboundTrafficRowSchema = z.object({
 export const OutboundTrafficRowSchema = z.object({

+ 809 - 0
util/link/outbound.go

@@ -0,0 +1,809 @@
+// Package link provides parsers for VPN share links (vmess://, vless://, etc.)
+// and subscription bodies (typically base64-encoded newline lists of such links).
+// The output shape matches the wire format used by the panel's Xray template
+// outbounds array so that parsed objects can be injected directly.
+package link
+
+import (
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"net/url"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+// Outbound is the minimal shape we emit for each parsed link.
+// Extra fields (mux, etc.) are carried inside settings/streamSettings.
+type Outbound map[string]any
+
+// ParseResult holds a parsed outbound together with a stable identity string
+// that can be used to correlate the same logical server across refreshes
+// (even if the remark changes).
+type ParseResult struct {
+	Outbound Outbound
+	Identity string
+}
+
+// ParseSubscriptionBody accepts the raw body returned by a subscription URL.
+// It handles the common case where the body is a base64-encoded blob of
+// newline-separated links, and also tolerates an already-decoded text body.
+// It returns the list of successfully parsed outbounds (in order) and their
+// corresponding identities.
+func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
+	text := strings.TrimSpace(string(body))
+	if text == "" {
+		return nil, nil, nil
+	}
+
+	// Try base64 decode first (standard and URL-safe variants).
+	if decoded, ok := tryBase64(text); ok {
+		text = strings.TrimSpace(decoded)
+	}
+
+	lines := splitLines(text)
+	var outbounds []Outbound
+	var identities []string
+
+	for _, ln := range lines {
+		ln = strings.TrimSpace(ln)
+		if ln == "" || strings.HasPrefix(ln, "#") {
+			continue
+		}
+		res, err := ParseLink(ln)
+		if err != nil || res == nil {
+			// Ignore unparseable lines (comments, unsupported protocols, etc.)
+			continue
+		}
+		outbounds = append(outbounds, res.Outbound)
+		identities = append(identities, res.Identity)
+	}
+	return outbounds, identities, nil
+}
+
+func tryBase64(s string) (string, bool) {
+	// Remove whitespace that some providers insert.
+	clean := strings.Map(func(r rune) rune {
+		if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
+			return -1
+		}
+		return r
+	}, s)
+
+	// Common padding fix
+	for len(clean)%4 != 0 {
+		clean += "="
+	}
+
+	// Standard
+	if b, err := base64.StdEncoding.DecodeString(clean); err == nil {
+		return string(b), true
+	}
+	// URL-safe (no padding)
+	if b, err := base64.RawURLEncoding.DecodeString(clean); err == nil {
+		return string(b), true
+	}
+	// URL-safe with padding
+	if b, err := base64.URLEncoding.DecodeString(clean); err == nil {
+		return string(b), true
+	}
+	return "", false
+}
+
+func splitLines(s string) []string {
+	// Accept \n, \r\n, and also some providers use literal \n in the text.
+	s = strings.ReplaceAll(s, `\n`, "\n")
+	return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' })
+}
+
+// ParseLink parses a single share link and returns the outbound object plus
+// a stable identity for tag correlation. Supported schemes:
+//   - vmess://
+//   - vless://
+//   - trojan://
+//   - ss:// (modern and legacy)
+//   - hysteria2:// (also hy2://)
+//   - wireguard:// (also wg://)
+func ParseLink(link string) (*ParseResult, error) {
+	link = strings.TrimSpace(link)
+	switch {
+	case strings.HasPrefix(link, "vmess://"):
+		return parseVmess(link)
+	case strings.HasPrefix(link, "vless://"):
+		return parseVless(link)
+	case strings.HasPrefix(link, "trojan://"):
+		return parseTrojan(link)
+	case strings.HasPrefix(link, "ss://"):
+		return parseShadowsocks(link)
+	case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
+		return parseHysteria2(link)
+	case strings.HasPrefix(link, "wireguard://"), strings.HasPrefix(link, "wg://"):
+		return parseWireguard(link)
+	default:
+		return nil, fmt.Errorf("unsupported link scheme")
+	}
+}
+
+// --- vmess ---
+
+func parseVmess(link string) (*ParseResult, error) {
+	b64 := strings.TrimPrefix(link, "vmess://")
+	// vmess:// base64(json)
+	raw, err := base64.StdEncoding.DecodeString(padBase64(b64))
+	if err != nil {
+		// Some providers use raw URL-safe
+		raw, err = base64.RawURLEncoding.DecodeString(b64)
+	}
+	if err != nil {
+		return nil, fmt.Errorf("vmess decode: %w", err)
+	}
+	var j map[string]any
+	if err := json.Unmarshal(raw, &j); err != nil {
+		return nil, fmt.Errorf("vmess json: %w", err)
+	}
+
+	identity := vmessIdentity(j)
+
+	network := getString(j, "net", "tcp")
+	security := "none"
+	if tls, _ := j["tls"].(string); tls == "tls" {
+		security = "tls"
+	}
+	stream := buildStream(network, security)
+
+	// Map known fields (best effort, matching frontend parser coverage)
+	switch network {
+	case "ws":
+		if host, ok := j["host"].(string); ok {
+			setWS(stream, host, getString(j, "path", "/"))
+		}
+	case "grpc":
+		svc := getString(j, "path", "")
+		if auth, ok := j["authority"].(string); ok && auth != "" {
+			(stream["grpcSettings"].(map[string]any))["authority"] = auth
+		}
+		(stream["grpcSettings"].(map[string]any))["serviceName"] = svc
+		(stream["grpcSettings"].(map[string]any))["multiMode"] = getString(j, "type", "") == "multi"
+	case "httpupgrade":
+		setHTTPUpgrade(stream, getString(j, "host", ""), getString(j, "path", "/"))
+	case "xhttp":
+		xh := stream["xhttpSettings"].(map[string]any)
+		xh["host"] = getString(j, "host", "")
+		xh["path"] = getString(j, "path", "/")
+		if m := getString(j, "mode", ""); m != "" {
+			xh["mode"] = m
+		}
+		// xhttp advanced keys are passed through if present in the json
+		for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
+			if v, ok := j[k]; ok {
+				xh[k] = v
+			}
+		}
+	case "tcp":
+		if getString(j, "type", "") == "http" {
+			stream["tcpSettings"] = map[string]any{
+				"header": map[string]any{
+					"type": "http",
+					"request": map[string]any{
+						"version": "1.1",
+						"method":  "GET",
+						"path":    splitComma(getString(j, "path", "/")),
+						"headers": map[string]any{"Host": splitComma(getString(j, "host", ""))},
+					},
+				},
+			}
+		}
+	}
+
+	if security == "tls" {
+		tls := stream["tlsSettings"].(map[string]any)
+		tls["serverName"] = getString(j, "sni", "")
+		tls["fingerprint"] = getString(j, "fp", "")
+		if alpn := getString(j, "alpn", ""); alpn != "" {
+			tls["alpn"] = splitComma(alpn)
+		}
+	}
+
+	port := num(j["port"])
+	ob := Outbound{
+		"protocol": "vmess",
+		"tag":      getString(j, "ps", ""),
+		"settings": map[string]any{
+			"vnext": []any{
+				map[string]any{
+					"address": getString(j, "add", ""),
+					"port":    port,
+					"users": []any{
+						map[string]any{
+							"id":       getString(j, "id", ""),
+							"security": getString(j, "scy", "auto"),
+						},
+					},
+				},
+			},
+		},
+		"streamSettings": stream,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+func vmessIdentity(j map[string]any) string {
+	// Remove ps (remark) for identity
+	core := map[string]any{}
+	for k, v := range j {
+		if k == "ps" {
+			continue
+		}
+		core[k] = v
+	}
+	b, _ := json.Marshal(core)
+	return "vmess:" + string(b)
+}
+
+// --- vless / trojan (URL forms) ---
+
+func parseVless(link string) (*ParseResult, error) {
+	u, err := url.Parse(link)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "vless" {
+		return nil, fmt.Errorf("not vless")
+	}
+	id := u.User.Username()
+	host := u.Hostname()
+	port := defaultPort(u.Port(), 443)
+	params := u.Query()
+	network := params.Get("type")
+	if network == "" {
+		network = "tcp"
+	}
+	security := params.Get("security")
+	if security == "" {
+		security = "none"
+	}
+	stream := buildStream(network, security)
+	applyTransport(stream, params)
+	applySecurity(stream, params)
+	applyFinalMask(stream, params)
+
+	identity := "vless:" + u.Scheme + "://" + id + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
+
+	ob := Outbound{
+		"protocol": "vless",
+		"tag":      decodeHash(u.Fragment),
+		"settings": map[string]any{
+			"address":    host,
+			"port":       port,
+			"id":         id,
+			"flow":       params.Get("flow"),
+			"encryption": firstNonEmpty(params.Get("encryption"), "none"),
+		},
+		"streamSettings": stream,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+func parseTrojan(link string) (*ParseResult, error) {
+	u, err := url.Parse(link)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "trojan" {
+		return nil, fmt.Errorf("not trojan")
+	}
+	pw := u.User.Username()
+	host := u.Hostname()
+	port := defaultPort(u.Port(), 443)
+	params := u.Query()
+	network := params.Get("type")
+	if network == "" {
+		network = "tcp"
+	}
+	security := params.Get("security")
+	if security == "" {
+		security = "tls"
+	}
+	stream := buildStream(network, security)
+	applyTransport(stream, params)
+	applySecurity(stream, params)
+	applyFinalMask(stream, params)
+
+	identity := "trojan:" + u.Scheme + "://" + pw + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
+
+	ob := Outbound{
+		"protocol": "trojan",
+		"tag":      decodeHash(u.Fragment),
+		"settings": map[string]any{
+			"servers": []any{
+				map[string]any{"address": host, "port": port, "password": pw},
+			},
+		},
+		"streamSettings": stream,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+// --- shadowsocks ---
+
+func parseShadowsocks(link string) (*ParseResult, error) {
+	// Two shapes:
+	//   ss://base64(method:pass)@host:port#remark
+	//   ss://base64(method:pass@host:port)#remark
+	remark := ""
+	if i := strings.Index(link, "#"); i >= 0 {
+		remark, _ = url.QueryUnescape(link[i+1:])
+		link = link[:i]
+	}
+	core := strings.TrimPrefix(link, "ss://")
+	at := strings.Index(core, "@")
+	if at >= 0 {
+		// modern
+		userB64 := core[:at]
+		hp := core[at+1:]
+		userInfo, err := base64DecodeFlexible(userB64)
+		if err != nil {
+			userInfo = userB64 // not b64, rare
+		}
+		colon := strings.LastIndex(hp, ":")
+		if colon < 0 {
+			return nil, fmt.Errorf("bad ss host:port")
+		}
+		host := hp[:colon]
+		port, _ := strconv.Atoi(hp[colon+1:])
+		method, pass := splitMethodPass(userInfo)
+		identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
+		ob := Outbound{
+			"protocol": "shadowsocks",
+			"tag":      remark,
+			"settings": map[string]any{
+				"servers": []any{
+					map[string]any{"address": host, "port": port, "password": pass, "method": method},
+				},
+			},
+		}
+		return &ParseResult{Outbound: ob, Identity: identity}, nil
+	}
+	// legacy: whole thing b64
+	dec, err := base64DecodeFlexible(core)
+	if err != nil {
+		return nil, err
+	}
+	at = strings.Index(dec, "@")
+	if at < 0 {
+		return nil, fmt.Errorf("bad legacy ss")
+	}
+	userInfo := dec[:at]
+	hp := dec[at+1:]
+	colon := strings.LastIndex(hp, ":")
+	if colon < 0 {
+		return nil, fmt.Errorf("bad legacy ss hp")
+	}
+	host := hp[:colon]
+	port, _ := strconv.Atoi(hp[colon+1:])
+	method, pass := splitMethodPass(userInfo)
+	identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
+	ob := Outbound{
+		"protocol": "shadowsocks",
+		"tag":      remark,
+		"settings": map[string]any{
+			"servers": []any{
+				map[string]any{"address": host, "port": port, "password": pass, "method": method},
+			},
+		},
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+func splitMethodPass(userInfo string) (string, string) {
+	colon := strings.Index(userInfo, ":")
+	if colon < 0 {
+		return "2022-blake3-aes-128-gcm", userInfo // guess
+	}
+	return userInfo[:colon], userInfo[colon+1:]
+}
+
+// --- hysteria2 ---
+
+func parseHysteria2(link string) (*ParseResult, error) {
+	u, err := url.Parse(link)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "hysteria2" && u.Scheme != "hy2" {
+		return nil, fmt.Errorf("not hysteria2")
+	}
+	auth := u.User.Username()
+	host := u.Hostname()
+	port := defaultPort(u.Port(), 443)
+	params := u.Query()
+
+	stream := map[string]any{
+		"network":  "hysteria",
+		"security": "tls",
+		"hysteriaSettings": map[string]any{
+			"version":        2,
+			"auth":           auth,
+			"udpIdleTimeout": 60,
+		},
+		"tlsSettings": map[string]any{
+			"serverName":           params.Get("sni"),
+			"alpn":                 splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
+			"fingerprint":          params.Get("fp"),
+			"echConfigList":        params.Get("ech"),
+			"verifyPeerCertByName": "",
+			"pinnedPeerCertSha256": params.Get("pinSHA256"),
+		},
+	}
+	applyFinalMask(stream, params)
+
+	identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
+
+	ob := Outbound{
+		"protocol":       "hysteria",
+		"tag":            decodeHash(u.Fragment),
+		"settings":       map[string]any{"address": host, "port": port, "version": 2},
+		"streamSettings": stream,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+// --- wireguard ---
+
+func parseWireguard(link string) (*ParseResult, error) {
+	u, err := url.Parse(link)
+	if err != nil {
+		return nil, err
+	}
+	if u.Scheme != "wireguard" && u.Scheme != "wg" {
+		return nil, fmt.Errorf("not wireguard")
+	}
+	secret, _ := url.QueryUnescape(u.User.Username())
+	params := u.Query()
+	host := u.Hostname()
+	portStr := u.Port()
+	endpoint := host
+	if portStr != "" {
+		endpoint = host + ":" + portStr
+	}
+
+	addrRaw := firstParam(params, "address", "ip")
+	allowedRaw := firstParam(params, "allowedips", "allowed_ips")
+	addrs := splitComma(addrRaw)
+	if len(addrs) == 0 {
+		addrs = []string{"0.0.0.0/0", "::/0"}
+	}
+	allowed := splitComma(allowedRaw)
+	if len(allowed) == 0 {
+		allowed = []string{"0.0.0.0/0", "::/0"}
+	}
+
+	peer := map[string]any{
+		"publicKey":  firstParam(params, "publickey", "publicKey", "public_key", "peerPublicKey"),
+		"endpoint":   endpoint,
+		"allowedIPs": allowed,
+	}
+	if psk := firstParam(params, "presharedkey", "preshared_key", "pre-shared-key", "psk"); psk != "" {
+		peer["preSharedKey"] = psk
+	}
+	if ka := firstParam(params, "keepalive", "persistentkeepalive", "persistent_keepalive"); ka != "" {
+		if n, err := strconv.Atoi(ka); err == nil {
+			peer["keepAlive"] = n
+		}
+	}
+
+	settings := map[string]any{
+		"secretKey": secret,
+		"address":   addrs,
+		"peers":     []any{peer},
+	}
+	if mtu := params.Get("mtu"); mtu != "" {
+		if n, err := strconv.Atoi(mtu); err == nil {
+			settings["mtu"] = n
+		}
+	}
+	if res := params.Get("reserved"); res != "" {
+		parts := splitComma(res)
+		var iv []int
+		for _, p := range parts {
+			if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
+				iv = append(iv, n)
+			}
+		}
+		if len(iv) > 0 {
+			settings["reserved"] = iv
+		}
+	}
+
+	identity := "wireguard:" + secret + "@" + endpoint + "?" + canonicalQuery(params)
+
+	ob := Outbound{
+		"protocol": "wireguard",
+		"tag":      decodeHash(u.Fragment),
+		"settings": settings,
+	}
+	return &ParseResult{Outbound: ob, Identity: identity}, nil
+}
+
+// --- helpers ---
+
+func buildStream(network, security string) map[string]any {
+	stream := map[string]any{"network": network, "security": security}
+	switch network {
+	case "tcp":
+		stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
+	case "kcp":
+		stream["kcpSettings"] = map[string]any{
+			"mtu": 1350, "tti": 20, "uplinkCapacity": 5, "downlinkCapacity": 20,
+			"cwndMultiplier": 1, "maxSendingWindow": 2097152,
+		}
+	case "ws":
+		stream["wsSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}, "heartbeatPeriod": 0}
+	case "grpc":
+		stream["grpcSettings"] = map[string]any{"serviceName": "", "authority": "", "multiMode": false}
+	case "httpupgrade":
+		stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
+	case "xhttp":
+		stream["xhttpSettings"] = map[string]any{
+			"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
+			"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
+		}
+	default:
+		stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
+	}
+	if security == "tls" {
+		stream["tlsSettings"] = map[string]any{
+			"serverName": "", "alpn": []any{}, "fingerprint": "",
+			"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
+		}
+	} else if security == "reality" {
+		stream["realitySettings"] = map[string]any{
+			"publicKey": "", "fingerprint": "chrome", "serverName": "",
+			"shortId": "", "spiderX": "", "mldsa65Verify": "",
+		}
+	}
+	return stream
+}
+
+func setWS(stream map[string]any, host, path string) {
+	ws := stream["wsSettings"].(map[string]any)
+	ws["host"] = host
+	ws["path"] = path
+}
+
+func setHTTPUpgrade(stream map[string]any, host, path string) {
+	h := stream["httpupgradeSettings"].(map[string]any)
+	h["host"] = host
+	h["path"] = path
+}
+
+func applyTransport(stream map[string]any, p url.Values) {
+	net := stream["network"].(string)
+	host := p.Get("host")
+	path := firstNonEmpty(p.Get("path"), "/")
+	switch net {
+	case "ws":
+		setWS(stream, host, path)
+	case "grpc":
+		gs := stream["grpcSettings"].(map[string]any)
+		gs["serviceName"] = firstNonEmpty(p.Get("serviceName"), p.Get("path"))
+		gs["authority"] = p.Get("authority")
+		gs["multiMode"] = p.Get("mode") == "multi"
+	case "httpupgrade":
+		setHTTPUpgrade(stream, host, path)
+	case "xhttp":
+		xh := stream["xhttpSettings"].(map[string]any)
+		xh["host"] = host
+		xh["path"] = path
+		if m := p.Get("mode"); m != "" {
+			xh["mode"] = m
+		}
+		// A few advanced xhttp fields that are commonly carried
+		for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
+			if v := p.Get(k); v != "" {
+				xh[k] = v
+			}
+		}
+	case "tcp":
+		if p.Get("headerType") == "http" || p.Get("type") == "http" {
+			stream["tcpSettings"] = map[string]any{
+				"header": map[string]any{
+					"type": "http",
+					"request": map[string]any{
+						"version": "1.1",
+						"method":  "GET",
+						"path":    splitComma(path),
+						"headers": map[string]any{"Host": splitComma(host)},
+					},
+				},
+			}
+		}
+	}
+}
+
+func applySecurity(stream map[string]any, p url.Values) {
+	sec := stream["security"].(string)
+	if sec == "tls" {
+		tls := stream["tlsSettings"].(map[string]any)
+		tls["serverName"] = p.Get("sni")
+		tls["fingerprint"] = p.Get("fp")
+		if alpn := p.Get("alpn"); alpn != "" {
+			tls["alpn"] = splitComma(alpn)
+		}
+		tls["echConfigList"] = p.Get("ech")
+		tls["pinnedPeerCertSha256"] = p.Get("pcs")
+	} else if sec == "reality" {
+		re := stream["realitySettings"].(map[string]any)
+		re["serverName"] = p.Get("sni")
+		re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
+		re["publicKey"] = p.Get("pbk")
+		re["shortId"] = p.Get("sid")
+		re["spiderX"] = p.Get("spx")
+		re["mldsa65Verify"] = p.Get("pqv")
+	}
+}
+
+func applyFinalMask(stream map[string]any, p url.Values) {
+	if fm := p.Get("fm"); fm != "" {
+		var parsed any
+		if json.Unmarshal([]byte(fm), &parsed) == nil {
+			stream["finalmask"] = parsed
+		}
+	}
+}
+
+func firstNonEmpty(a, b string) string {
+	if a != "" {
+		return a
+	}
+	return b
+}
+
+func firstParam(p url.Values, keys ...string) string {
+	for _, k := range keys {
+		if v := p.Get(k); v != "" {
+			return v
+		}
+	}
+	return ""
+}
+
+func canonicalQuery(p url.Values) string {
+	// Sort keys for stable identity
+	keys := make([]string, 0, len(p))
+	for k := range p {
+		keys = append(keys, k)
+	}
+	// simple sort
+	for i := 0; i < len(keys); i++ {
+		for j := i + 1; j < len(keys); j++ {
+			if keys[j] < keys[i] {
+				keys[i], keys[j] = keys[j], keys[i]
+			}
+		}
+	}
+	parts := make([]string, 0, len(keys))
+	for _, k := range keys {
+		for _, v := range p[k] {
+			parts = append(parts, k+"="+v)
+		}
+	}
+	return strings.Join(parts, "&")
+}
+
+func decodeHash(h string) string {
+	if h == "" {
+		return ""
+	}
+	if dec, err := url.QueryUnescape(h); err == nil {
+		return dec
+	}
+	return h
+}
+
+func defaultPort(p string, def int) int {
+	if p == "" {
+		return def
+	}
+	n, err := strconv.Atoi(p)
+	if err != nil || n <= 0 {
+		return def
+	}
+	return n
+}
+
+func num(v any) int {
+	switch x := v.(type) {
+	case float64:
+		return int(x)
+	case int:
+		return x
+	case int64:
+		return int(x)
+	case string:
+		n, _ := strconv.Atoi(x)
+		return n
+	}
+	return 0
+}
+
+func getString(m map[string]any, key, def string) string {
+	if v, ok := m[key]; ok {
+		if s, ok := v.(string); ok {
+			return s
+		}
+	}
+	return def
+}
+
+func splitComma(s string) []string {
+	if s == "" {
+		return nil
+	}
+	parts := strings.Split(s, ",")
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		p = strings.TrimSpace(p)
+		if p != "" {
+			out = append(out, p)
+		}
+	}
+	return out
+}
+
+func splitCommaOrDefault(s string, def []string) []string {
+	if s == "" {
+		return def
+	}
+	return splitComma(s)
+}
+
+func padBase64(s string) string {
+	for len(s)%4 != 0 {
+		s += "="
+	}
+	return s
+}
+
+func base64DecodeFlexible(s string) (string, error) {
+	s = padBase64(s)
+	if b, err := base64.StdEncoding.DecodeString(s); err == nil {
+		return string(b), nil
+	}
+	if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil {
+		return string(b), nil
+	}
+	return "", fmt.Errorf("base64 decode failed")
+}
+
+// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
+var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
+
+func SlugRemark(remark string) string {
+	s := strings.ToLower(strings.TrimSpace(remark))
+	s = slugRe.ReplaceAllString(s, "-")
+	s = strings.Trim(s, "-")
+	if s == "" {
+		return ""
+	}
+	// collapse runs of dashes
+	for strings.Contains(s, "--") {
+		s = strings.ReplaceAll(s, "--", "-")
+	}
+	return s
+}
+
+// SuggestTag builds a tag from a prefix and a remark (or index fallback).
+// It is intended for initial assignment; stability is handled by the service layer.
+func SuggestTag(prefix, remark string, idx int) string {
+	base := SlugRemark(remark)
+	if base == "" {
+		base = fmt.Sprintf("%d", idx)
+	}
+	p := strings.TrimSuffix(prefix, "-")
+	if p != "" {
+		return p + "-" + base
+	}
+	return base
+}

+ 62 - 0
util/link/outbound_test.go

@@ -0,0 +1,62 @@
+package link
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestParseVmessLink(t *testing.T) {
+	// vmess:// + base64 of:
+	// {"v":"2","ps":"test","add":"1.2.3.4","port":443,"id":"uuid","aid":"0","net":"ws","type":"","host":"ex.com","path":"/","tls":"tls"}
+	link := "vmess://eyJ2IjoiMiIsInBzIjoidGVzdCIsImFkZCI6IjEuMi4zLjQiLCJwb3J0Ijo0NDMsImlkIjoidXVpZCIsImFpZCI6IjAiLCJuZXQiOiJ3cyIsInR5cGUiOiIiLCJob3N0IjoiZXguY29tIiwicGF0aCI6Ii8iLCJ0bHMiOiJ0bHMifQ=="
+	res, err := ParseLink(link)
+	if err != nil {
+		t.Fatalf("parse vmess: %v", err)
+	}
+	if res.Outbound["protocol"] != "vmess" {
+		t.Errorf("expected vmess protocol, got %v", res.Outbound["protocol"])
+	}
+	if res.Outbound["tag"] != "test" {
+		t.Errorf("expected tag 'test', got %v", res.Outbound["tag"])
+	}
+}
+
+func TestParseVlessLink(t *testing.T) {
+	link := "vless://[email protected]:443?type=ws&security=tls&path=/&host=ex.com#node1"
+	res, err := ParseLink(link)
+	if err != nil {
+		t.Fatalf("parse vless: %v", err)
+	}
+	if res.Outbound["protocol"] != "vless" {
+		t.Fatalf("bad protocol")
+	}
+	if res.Outbound["tag"] != "node1" {
+		t.Errorf("tag mismatch: %v", res.Outbound["tag"])
+	}
+}
+
+func TestParseSubscriptionBody_Base64(t *testing.T) {
+	// base64 of the two joined links:
+	// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
+	b64 := "dmxlc3M6Ly91QGg6NDQzP3R5cGU9dGNwI0EKdmxlc3M6Ly91MkBoMjo0NDM/dHlwZT10Y3AjQg=="
+	obs, ids, err := ParseSubscriptionBody([]byte(b64))
+	if err != nil {
+		t.Fatalf("parse sub body: %v", err)
+	}
+	if len(obs) != 2 {
+		t.Fatalf("expected 2 outbounds, got %d", len(obs))
+	}
+	if !strings.HasPrefix(ids[0], "vless:") || !strings.HasPrefix(ids[1], "vless:") {
+		t.Errorf("bad identities: %v", ids)
+	}
+}
+
+func TestSlugAndSuggest(t *testing.T) {
+	if SlugRemark("Hello World!") != "hello-world" {
+		t.Errorf("slug failed")
+	}
+	tag := SuggestTag("hk-", "  SG 01 !! ", 0)
+	if tag != "hk-sg-01" {
+		t.Errorf("suggest tag got %q", tag)
+	}
+}

+ 173 - 4
web/controller/xray_setting.go

@@ -2,6 +2,7 @@ package controller
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
+	"fmt"
 
 
 	"github.com/mhsanaei/3x-ui/v3/util/common"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
@@ -14,10 +15,11 @@ type XraySettingController struct {
 	XraySettingService service.XraySettingService
 	XraySettingService service.XraySettingService
 	SettingService     service.SettingService
 	SettingService     service.SettingService
 	InboundService     service.InboundService
 	InboundService     service.InboundService
-	OutboundService    service.OutboundService
-	XrayService        service.XrayService
-	WarpService        service.WarpService
-	NordService        service.NordService
+	OutboundService          service.OutboundService
+	XrayService              service.XrayService
+	WarpService              service.WarpService
+	NordService              service.NordService
+	OutboundSubscriptionService service.OutboundSubscriptionService
 }
 }
 
 
 // NewXraySettingController creates a new XraySettingController and initializes its routes.
 // NewXraySettingController creates a new XraySettingController and initializes its routes.
@@ -40,6 +42,16 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/update", a.updateSetting)
 	g.POST("/update", a.updateSetting)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 	g.POST("/testOutbound", a.testOutbound)
 	g.POST("/testOutbound", a.testOutbound)
+
+	// Outbound subscription (remote outbound lists)
+	g.GET("/outbound-subs", a.listOutboundSubs)
+	g.POST("/outbound-subs", a.createOutboundSub)
+	g.POST("/outbound-subs/:id/refresh", a.refreshOutboundSub)
+	g.POST("/outbound-subs/:id/move", a.moveOutboundSub)
+	g.POST("/outbound-subs/:id", a.updateOutboundSub)
+	g.DELETE("/outbound-subs/:id", a.deleteOutboundSub)
+	g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // axios-friendly alias
+	g.POST("/outbound-subs/parse", a.parseOutboundSubURL) // preview without saving
 }
 }
 
 
 // getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
 // getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
@@ -85,6 +97,17 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
 		"clientReverseTags": json.RawMessage(clientReverseTags),
 		"clientReverseTags": json.RawMessage(clientReverseTags),
 		"outboundTestUrl":   outboundTestUrl,
 		"outboundTestUrl":   outboundTestUrl,
 	}
 	}
+
+	// Surface subscription outbounds (and their tags) so the frontend can:
+	// - show them as read-only items in the Outbounds tab
+	// - let users pick them in balancers and routing rules
+	// These are not part of the editable template; they are injected at runtime.
+	if subObs, err := a.OutboundSubscriptionService.AllActiveOutbounds(); err == nil && len(subObs) > 0 {
+		xrayResponse["subscriptionOutbounds"] = subObs
+	}
+	if subTags, err := a.OutboundSubscriptionService.AllActiveOutboundTags(); err == nil && len(subTags) > 0 {
+		xrayResponse["subscriptionOutboundTags"] = subTags
+	}
 	result, err := json.Marshal(xrayResponse)
 	result, err := json.Marshal(xrayResponse)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
@@ -227,3 +250,149 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
 
 
 	jsonObj(c, result, nil)
 	jsonObj(c, result, nil)
 }
 }
+
+// --- Outbound Subscription handlers ---
+
+func (a *XraySettingController) listOutboundSubs(c *gin.Context) {
+	list, err := a.OutboundSubscriptionService.List()
+	if err != nil {
+		jsonMsg(c, "Failed to list outbound subscriptions", err)
+		return
+	}
+	jsonObj(c, list, nil)
+}
+
+func (a *XraySettingController) createOutboundSub(c *gin.Context) {
+	remark := c.PostForm("remark")
+	rawURL := c.PostForm("url")
+	prefix := c.PostForm("tagPrefix")
+	enabled := c.PostForm("enabled") != "false"
+	allowPrivate := c.PostForm("allowPrivate") == "true"
+	prepend := c.PostForm("prepend") == "true"
+	intervalStr := c.PostForm("updateInterval")
+	interval := 600
+	if intervalStr != "" {
+		if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
+			interval = v
+		}
+	}
+	sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend)
+	if err != nil {
+		jsonMsg(c, "Failed to create outbound subscription", err)
+		return
+	}
+	jsonObj(c, sub, nil)
+}
+
+func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
+	id := c.Param("id")
+	var subID int
+	if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
+		jsonMsg(c, "Invalid id", err)
+		return
+	}
+	remark := c.PostForm("remark")
+	rawURL := c.PostForm("url")
+	prefix := c.PostForm("tagPrefix")
+	enabled := c.PostForm("enabled") != "false"
+	allowPrivate := c.PostForm("allowPrivate") == "true"
+	prepend := c.PostForm("prepend") == "true"
+	intervalStr := c.PostForm("updateInterval")
+	interval := 600
+	if intervalStr != "" {
+		if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
+			interval = v
+		}
+	}
+	if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend); err != nil {
+		jsonMsg(c, "Failed to update outbound subscription", err)
+		return
+	}
+	jsonObj(c, "", nil)
+}
+
+func (a *XraySettingController) deleteOutboundSub(c *gin.Context) {
+	id := c.Param("id")
+	var subID int
+	if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
+		jsonMsg(c, "Invalid id", err)
+		return
+	}
+	if err := a.OutboundSubscriptionService.Delete(subID); err != nil {
+		jsonMsg(c, "Failed to delete outbound subscription", err)
+		return
+	}
+	// Signal that xray should drop this subscription's outbounds on next reload.
+	a.XrayService.SetToNeedRestart()
+	jsonObj(c, "", nil)
+}
+
+func (a *XraySettingController) refreshOutboundSub(c *gin.Context) {
+	id := c.Param("id")
+	var subID int
+	if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
+		jsonMsg(c, "Invalid id", err)
+		return
+	}
+	obs, err := a.OutboundSubscriptionService.Refresh(subID)
+	if err != nil {
+		jsonMsg(c, "Refresh failed", err)
+		return
+	}
+	// Signal that xray should pick up the new outbounds on next restart/reload
+	a.XrayService.SetToNeedRestart()
+	jsonObj(c, obs, nil)
+}
+
+func (a *XraySettingController) moveOutboundSub(c *gin.Context) {
+	id := c.Param("id")
+	var subID int
+	if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
+		jsonMsg(c, "Invalid id", err)
+		return
+	}
+	up := c.PostForm("dir") == "up"
+	if err := a.OutboundSubscriptionService.Move(subID, up); err != nil {
+		jsonMsg(c, "Failed to reorder outbound subscription", err)
+		return
+	}
+	// Order affects the merged outbounds, so xray needs a reload.
+	a.XrayService.SetToNeedRestart()
+	jsonObj(c, "", nil)
+}
+
+// parseOutboundSubURL is a preview endpoint: it fetches + parses the provided
+// URL but does not persist anything. Useful for the "add subscription" flow
+// so the user can see the resulting outbounds (and assigned tags) before saving.
+func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
+	rawURL := c.PostForm("url")
+	if rawURL == "" {
+		jsonMsg(c, "url is required", common.NewError("missing url"))
+		return
+	}
+	allowPrivate := c.PostForm("allowPrivate") == "true"
+	// Use a throw-away service instance; it only needs the settingService for proxy.
+	svc := service.OutboundSubscriptionService{}
+	// We don't have a direct "fetch once" that returns without storing, so we
+	// temporarily create a disabled row, refresh it, then delete. Cleaner would
+	// be to expose a pure ParseURL on the service, but this keeps the surface small.
+	tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false)
+	if err != nil {
+		jsonMsg(c, "Failed to preview subscription", err)
+		return
+	}
+	obs, err := svc.Refresh(tmp.Id)
+	// best-effort cleanup
+	_ = svc.Delete(tmp.Id)
+	if err != nil {
+		jsonMsg(c, "Failed to fetch/parse subscription", err)
+		return
+	}
+	jsonObj(c, obs, nil)
+}
+
+func parseIntSafe(s string) (int, error) {
+	var v int
+	_, err := fmt.Sscanf(s, "%d", &v)
+	return v, err
+}

+ 48 - 0
web/job/outbound_subscription_job.go

@@ -0,0 +1,48 @@
+package job
+
+import (
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+	"github.com/mhsanaei/3x-ui/v3/web/websocket"
+)
+
+// OutboundSubscriptionJob periodically re-fetches enabled outbound subscriptions,
+// updates the stored outbounds (with stable tags), and signals that xray
+// should be reloaded so the new outbounds take effect.
+type OutboundSubscriptionJob struct {
+	subService *service.OutboundSubscriptionService
+	xraySvc    *service.XrayService
+}
+
+// NewOutboundSubscriptionJob creates the job (zero-value services are populated
+// on first Run via method calls, same pattern as other jobs).
+func NewOutboundSubscriptionJob() *OutboundSubscriptionJob {
+	return &OutboundSubscriptionJob{
+		subService: &service.OutboundSubscriptionService{},
+		xraySvc:    &service.XrayService{},
+	}
+}
+
+// Run is invoked by the cron scheduler.
+func (j *OutboundSubscriptionJob) Run() {
+	if j.subService == nil {
+		j.subService = &service.OutboundSubscriptionService{}
+	}
+	if j.xraySvc == nil {
+		j.xraySvc = &service.XrayService{}
+	}
+
+	count, err := j.subService.RefreshAllEnabled()
+	if err != nil {
+		logger.Warning("outbound subscription auto-update error:", err)
+		return
+	}
+	if count > 0 {
+		logger.Infof("Refreshed %d outbound subscription(s)", count)
+		// Ask the xray manager to restart/reload on the next 30s check.
+		j.xraySvc.SetToNeedRestart()
+		// Also broadcast an invalidate so the UI can refresh the xray setting
+		// view (new outbounds will be visible after the reload cycle).
+		websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
+	}
+}

+ 540 - 0
web/service/outbound_subscription.go

@@ -0,0 +1,540 @@
+package service
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"regexp"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/database"
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/logger"
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/link"
+)
+
+// OutboundSubscriptionService manages remote outbound subscriptions.
+type OutboundSubscriptionService struct {
+	settingService SettingService
+}
+
+// NewOutboundSubscriptionService returns a service for managing outbound subscriptions.
+func NewOutboundSubscriptionService() *OutboundSubscriptionService {
+	return &OutboundSubscriptionService{}
+}
+
+// List returns all subscriptions (newest first).
+func (s *OutboundSubscriptionService) List() ([]*model.OutboundSubscription, error) {
+	db := database.GetDB()
+	var subs []*model.OutboundSubscription
+	if err := db.Model(&model.OutboundSubscription{}).Order("priority asc, id asc").Find(&subs).Error; err != nil {
+		return nil, err
+	}
+	for _, sub := range subs {
+		sub.OutboundCount = countOutbounds(sub.LastFetchedOutbounds)
+		// Don't ship the heavy raw blobs to the list view.
+		sub.LastFetchedOutbounds = ""
+		sub.LinkIdentities = ""
+	}
+	return subs, nil
+}
+
+// countOutbounds returns the number of outbounds in a stored LastFetchedOutbounds
+// JSON array (0 for empty/invalid).
+func countOutbounds(raw string) int {
+	if strings.TrimSpace(raw) == "" {
+		return 0
+	}
+	var arr []any
+	if json.Unmarshal([]byte(raw), &arr) != nil {
+		return 0
+	}
+	return len(arr)
+}
+
+// Get returns a single subscription by id.
+func (s *OutboundSubscriptionService) Get(id int) (*model.OutboundSubscription, error) {
+	db := database.GetDB()
+	var sub model.OutboundSubscription
+	if err := db.First(&sub, id).Error; err != nil {
+		return nil, err
+	}
+	return &sub, nil
+}
+
+// Create persists a new subscription. It does not fetch immediately; the caller
+// can call Refresh on the returned id if desired.
+var defaultPrefixRe = regexp.MustCompile(`^sub(\d+)-$`)
+
+// defaultPrefixNumber returns the smallest positive integer N that is not already
+// in use as a "subN-" tag prefix among the given subscriptions. This is used to
+// auto-name a subscription's outbounds when the user leaves the prefix blank, so
+// deleting a subscription frees its number for reuse instead of letting the
+// number grow forever with the auto-increment DB id. A subscription with a blank
+// prefix reserves its own id (it falls back to id-based "sub<id>-" tags).
+func defaultPrefixNumber(subs []*model.OutboundSubscription, excludeId int) int {
+	used := map[int]bool{}
+	for _, sub := range subs {
+		if sub.Id == excludeId {
+			continue
+		}
+		if sub.TagPrefix == "" {
+			used[sub.Id] = true
+			continue
+		}
+		if m := defaultPrefixRe.FindStringSubmatch(sub.TagPrefix); m != nil {
+			if n, err := strconv.Atoi(m[1]); err == nil {
+				used[n] = true
+			}
+		}
+	}
+	n := 1
+	for used[n] {
+		n++
+	}
+	return n
+}
+
+// nextDefaultSubPrefix builds the default "subN-" prefix for a new/edited
+// subscription, picking the smallest free N (excludeId skips a subscription's
+// own current prefix when editing).
+func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string {
+	var subs []*model.OutboundSubscription
+	_ = database.GetDB().Find(&subs).Error
+	return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
+}
+
+func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) (*model.OutboundSubscription, error) {
+	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
+	if err != nil {
+		return nil, common.NewError("invalid subscription URL:", err)
+	}
+	if cleanURL == "" {
+		return nil, common.NewError("subscription URL is required")
+	}
+	if updateInterval <= 0 {
+		updateInterval = 600
+	}
+	prefix := strings.TrimSpace(tagPrefix)
+	if prefix == "" {
+		prefix = s.nextDefaultSubPrefix(0)
+	}
+	// New subscriptions go to the end of the priority order.
+	var count int64
+	database.GetDB().Model(&model.OutboundSubscription{}).Count(&count)
+	sub := &model.OutboundSubscription{
+		Remark:         strings.TrimSpace(remark),
+		Url:            cleanURL,
+		Enabled:        enabled,
+		AllowPrivate:   allowPrivate,
+		Prepend:        prepend,
+		Priority:       int(count),
+		TagPrefix:      prefix,
+		UpdateInterval: updateInterval,
+	}
+	if err := database.GetDB().Create(sub).Error; err != nil {
+		return nil, err
+	}
+	return sub, nil
+}
+
+// Update updates editable fields.
+func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) error {
+	sub, err := s.Get(id)
+	if err != nil {
+		return err
+	}
+	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
+	if err != nil {
+		return common.NewError("invalid subscription URL:", err)
+	}
+	if cleanURL == "" {
+		return common.NewError("subscription URL is required")
+	}
+	if updateInterval <= 0 {
+		updateInterval = 600
+	}
+	prefix := strings.TrimSpace(tagPrefix)
+	if prefix == "" {
+		prefix = s.nextDefaultSubPrefix(sub.Id)
+	}
+	sub.Remark = strings.TrimSpace(remark)
+	sub.Url = cleanURL
+	sub.Enabled = enabled
+	sub.AllowPrivate = allowPrivate
+	sub.Prepend = prepend
+	sub.TagPrefix = prefix
+	sub.UpdateInterval = updateInterval
+	return database.GetDB().Save(sub).Error
+}
+
+// Delete removes a subscription.
+func (s *OutboundSubscriptionService) Delete(id int) error {
+	return database.GetDB().Delete(&model.OutboundSubscription{}, id).Error
+}
+
+// GetLastOutbounds returns the last successfully fetched outbounds for a subscription
+// (as raw interface slice ready for JSON merge). Returns nil slice when none.
+func (s *OutboundSubscriptionService) GetLastOutbounds(id int) ([]any, error) {
+	sub, err := s.Get(id)
+	if err != nil {
+		return nil, err
+	}
+	if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
+		return nil, nil
+	}
+	var arr []any
+	if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
+		return nil, err
+	}
+	return arr, nil
+}
+
+// Refresh fetches the subscription URL, parses the links, assigns stable tags,
+// persists the results, and returns the generated outbounds.
+func (s *OutboundSubscriptionService) Refresh(id int) ([]any, error) {
+	sub, err := s.Get(id)
+	if err != nil {
+		return nil, err
+	}
+	outbounds, err := s.fetchAndStore(sub)
+	return outbounds, err
+}
+
+// RefreshAllEnabled fetches every enabled subscription whose due time has passed
+// (lastUpdated + updateInterval <= now). It returns the number of subscriptions
+// that were actually refreshed.
+func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
+	db := database.GetDB()
+	var subs []*model.OutboundSubscription
+	if err := db.Where("enabled = ?", true).Find(&subs).Error; err != nil {
+		return 0, err
+	}
+	now := time.Now().Unix()
+	refreshed := 0
+	for _, sub := range subs {
+		due := sub.LastUpdated + int64(sub.UpdateInterval)
+		if sub.LastUpdated == 0 || due <= now {
+			if _, err := s.fetchAndStore(sub); err != nil {
+				logger.Warningf("outbound sub %d (%s) refresh failed: %v", sub.Id, sub.Remark, err)
+				// continue with others
+			} else {
+				refreshed++
+			}
+		}
+	}
+	return refreshed, nil
+}
+
+// fetchAndStore does the actual network + parse + stability + persist work.
+func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) {
+	// Re-sanitize on every fetch (handles legacy rows + defense in depth against
+	// any direct DB tampering). Private targets are blocked unless this
+	// subscription was explicitly created with AllowPrivate.
+	cleanURL, err := SanitizePublicHTTPURL(sub.Url, sub.AllowPrivate)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+	if cleanURL == "" {
+		return nil, common.NewError("subscription has no valid URL")
+	}
+	sub.Url = cleanURL // persist the cleaned version
+
+	client := s.settingService.NewProxiedHTTPClient(30 * time.Second)
+	// Re-validate every redirect hop: the initial host is checked above, but a
+	// redirect could still point at a private/internal address (SSRF). Cap the
+	// redirect chain as well.
+	client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+		if len(via) >= 10 {
+			return fmt.Errorf("stopped after 10 redirects")
+		}
+		if sub.AllowPrivate {
+			return nil
+		}
+		ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
+		defer cancel()
+		return rejectPrivateHost(ctx, req.URL.Hostname())
+	}
+
+	req, err := http.NewRequest("GET", sub.Url, nil)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+	req.Header.Set("User-Agent", "3x-ui-outbound-sub/1.0")
+
+	resp, err := client.Do(req)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		err := fmt.Errorf("http %d", resp.StatusCode)
+		s.recordError(sub, err)
+		return nil, err
+	}
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+
+	parsed, identities, err := link.ParseSubscriptionBody(body)
+	if err != nil {
+		s.recordError(sub, err)
+		return nil, err
+	}
+
+	// Load previous identities -> tags for stability
+	prev := map[string]string{}
+	if strings.TrimSpace(sub.LinkIdentities) != "" {
+		_ = json.Unmarshal([]byte(sub.LinkIdentities), &prev)
+	}
+
+	// Also load previous outbounds so we can reuse tags even for identities we
+	// temporarily lost (defensive).
+	prevTagByIndex := map[int]string{}
+	if strings.TrimSpace(sub.LastFetchedOutbounds) != "" {
+		var prevObs []any
+		if json.Unmarshal([]byte(sub.LastFetchedOutbounds), &prevObs) == nil {
+			for i, o := range prevObs {
+				if m, ok := o.(map[string]any); ok {
+					if tag, _ := m["tag"].(string); tag != "" {
+						prevTagByIndex[i] = tag
+					}
+				}
+			}
+		}
+	}
+
+	// Assign tags with stability (identity reuse, positional fallback, then a
+	// fresh allocation), keeping tags unique within this batch. Extracted into a
+	// pure function so it can be unit-tested without network/DB. Tags are written
+	// back into the parsed outbounds in place.
+	assigned := assignStableTags(parsed, identities, prev, prevTagByIndex, sub.Id, sub.TagPrefix)
+
+	// Persist identities for next time
+	newIdent := map[string]string{}
+	for i, id := range identities {
+		newIdent[id] = assigned[i]
+	}
+	identJSON, _ := json.Marshal(newIdent)
+
+	// Persist the outbounds (as compact JSON array)
+	obsJSON, _ := json.Marshal(parsed)
+
+	sub.LastFetchedOutbounds = string(obsJSON)
+	sub.LinkIdentities = string(identJSON)
+	sub.LastUpdated = time.Now().Unix()
+	sub.LastError = ""
+
+	if err := database.GetDB().Save(sub).Error; err != nil {
+		return nil, err
+	}
+
+	// Return as []any for the config merger
+	result := make([]any, len(parsed))
+	for i := range parsed {
+		result[i] = parsed[i]
+	}
+	return result, nil
+}
+
+func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
+	sub.LastError = err.Error()
+	_ = database.GetDB().Model(sub).Update("last_error", sub.LastError).Error
+}
+
+// assignStableTags assigns a tag to each parsed outbound, preferring stability:
+//  1. reuse the tag previously mapped to the link's identity (prev),
+//  2. else reuse the tag at the same position from the last fetch (prevTagByIndex),
+//  3. else allocate a fresh tag from the prefix + remark (link.SuggestTag).
+//
+// Tags are kept unique within the batch by appending "-N" on collision, and are
+// written back into parsed[i]["tag"]. The returned slice holds the assigned tags
+// in order. When tagPrefix is empty a "sub<subID>-" prefix is used for fresh tags.
+func assignStableTags(parsed []link.Outbound, identities []string, prev map[string]string, prevTagByIndex map[int]string, subID int, tagPrefix string) []string {
+	used := map[string]bool{} // uniqueness within this refresh batch
+	assigned := make([]string, len(parsed))
+	for i := range parsed {
+		id := ""
+		if i < len(identities) {
+			id = identities[i]
+		}
+		candidate := ""
+		if old, ok := prev[id]; ok && old != "" {
+			candidate = old
+		}
+		if candidate == "" {
+			// try to reuse by rough positional match from previous fetch (best effort)
+			if old, ok := prevTagByIndex[i]; ok && old != "" {
+				candidate = old
+			}
+		}
+		if candidate == "" {
+			// fresh allocation
+			prefix := tagPrefix
+			if prefix == "" {
+				prefix = fmt.Sprintf("sub%d-", subID)
+			}
+			remark := ""
+			if m, ok := parsed[i]["tag"].(string); ok {
+				remark = m
+			}
+			candidate = link.SuggestTag(prefix, remark, i)
+		}
+		// ensure local uniqueness inside this batch
+		final := candidate
+		for k := 1; used[final]; k++ {
+			final = fmt.Sprintf("%s-%d", candidate, k)
+		}
+		used[final] = true
+		assigned[i] = final
+
+		// write back the tag into the outbound
+		parsed[i]["tag"] = final
+	}
+	return assigned
+}
+
+// AllActiveOutbounds returns the concatenation of the last-fetched outbounds
+// for every enabled subscription. This is the set that should be merged into
+// the final Xray config. Order: subscription creation order (by id asc) so
+// that later subscriptions can shadow earlier ones if the admin uses colliding
+// prefixes (last writer wins inside xray, but we try to keep tags unique).
+func (s *OutboundSubscriptionService) AllActiveOutbounds() ([]any, error) {
+	prepend, appendList, err := s.activeOutboundsSplit()
+	if err != nil {
+		return nil, err
+	}
+	return append(prepend, appendList...), nil
+}
+
+// activeOutboundsSplit returns the active subscription outbounds split into those
+// that should be placed BEFORE the manual template outbounds (Prepend) and those
+// placed AFTER. Within each group, subscriptions are ordered by Priority (then id)
+// so the admin can control the merged order.
+func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, appendList []any, err error) {
+	db := database.GetDB()
+	var subs []*model.OutboundSubscription
+	if err := db.Where("enabled = ?", true).Order("priority asc, id asc").Find(&subs).Error; err != nil {
+		return nil, nil, err
+	}
+	for _, sub := range subs {
+		if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
+			continue
+		}
+		var arr []any
+		if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
+			logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
+			continue
+		}
+		if sub.Prepend {
+			prepend = append(prepend, arr...)
+		} else {
+			appendList = append(appendList, arr...)
+		}
+	}
+	return prepend, appendList, nil
+}
+
+// Move shifts a subscription one step up or down in the priority order and
+// re-normalizes all priorities to a 0..n-1 sequence.
+func (s *OutboundSubscriptionService) Move(id int, up bool) error {
+	db := database.GetDB()
+	var subs []*model.OutboundSubscription
+	if err := db.Order("priority asc, id asc").Find(&subs).Error; err != nil {
+		return err
+	}
+	idx := -1
+	for i, sub := range subs {
+		if sub.Id == id {
+			idx = i
+			break
+		}
+	}
+	if idx == -1 {
+		return common.NewError("subscription not found")
+	}
+	swap := idx + 1
+	if up {
+		swap = idx - 1
+	}
+	if swap < 0 || swap >= len(subs) {
+		return nil // already at the edge
+	}
+	subs[idx], subs[swap] = subs[swap], subs[idx]
+	for i, sub := range subs {
+		if sub.Priority != i {
+			if err := db.Model(sub).Update("priority", i).Error; err != nil {
+				return err
+			}
+		}
+	}
+	return nil
+}
+
+// AllActiveOutboundTags returns only the tags of active subscription outbounds.
+// Useful for populating balancer / routing selectors without shipping full objects.
+func (s *OutboundSubscriptionService) AllActiveOutboundTags() ([]string, error) {
+	obs, err := s.AllActiveOutbounds()
+	if err != nil {
+		return nil, err
+	}
+	tags := make([]string, 0, len(obs))
+	for _, o := range obs {
+		if m, ok := o.(map[string]any); ok {
+			if t, _ := m["tag"].(string); t != "" {
+				tags = append(tags, t)
+			}
+		}
+	}
+	return tags, nil
+}
+
+/*
+Tag stability strategy (important for balancers and routing rules)
+
+When a subscription is refreshed we try very hard to keep the *same* tag for the
+same logical outbound so that existing balancers and routing rules keep working.
+
+How we do it:
+- On every successful parse we compute a stable "identity" for each link
+  (the core of the URI with the remark fragment removed, or for vmess the inner
+  JSON without the "ps" field).
+- We persist a map identity -> tag in the LinkIdentities column.
+- On the next refresh, if we see the same identity again we reuse the previous tag,
+  even if the remark changed or minor parameters moved.
+- Only when we have never seen the identity before do we allocate a fresh tag
+  using the user-supplied TagPrefix + slug(remark) (or an index fallback).
+- Within one refresh we still deduplicate with -N suffixes.
+
+Consequences for balancers / routing:
+- If you use an *exact* tag in a balancer selector or a routing rule, that
+  specific server will continue to be used after refreshes (as long as the
+  provider still returns a link that produces the same identity).
+- If you use a *prefix/wildcard* selector (e.g. "hk-*", "sg-.*"), then any
+  *new* servers that the subscription later returns will automatically be
+  eligible for that balancer on the next Xray reload — this is the recommended
+  way to "subscribe to a pool".
+- When a server disappears from the subscription, its tag simply stops
+  existing in the final outbounds array. The balancer will have fewer
+  candidates. If you configured a `fallbackTag` on the balancer, Xray will use
+  it. Otherwise connections that would have used the missing member may fail
+  or be routed by the next rule.
+- If the provider rotates credentials/UUIDs/hosts for a server, the identity
+  changes → we treat it as a brand new outbound and give it a new tag. Any
+  balancer/rule that referenced the *old* tag will no longer see it. This is
+  an inherent limitation of subscription-based outbounds.
+
+We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription
+outbounds are always injected at runtime in GetXrayConfig.
+*/

+ 117 - 0
web/service/outbound_subscription_test.go

@@ -0,0 +1,117 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/util/link"
+)
+
+func TestDefaultPrefixNumber(t *testing.T) {
+	mk := func(id int, prefix string) *model.OutboundSubscription {
+		return &model.OutboundSubscription{Id: id, TagPrefix: prefix}
+	}
+	cases := []struct {
+		name      string
+		subs      []*model.OutboundSubscription
+		excludeId int
+		want      int
+	}{
+		{"no subscriptions starts at 1", nil, 0, 1},
+		{"sequential prefixes give the next", []*model.OutboundSubscription{mk(1, "sub1-"), mk(2, "sub2-")}, 0, 3},
+		{"reuses the lowest freed number", []*model.OutboundSubscription{mk(2, "sub2-")}, 0, 1},
+		{"legacy blank prefix reserves its id", []*model.OutboundSubscription{mk(1, ""), mk(5, "sub3-")}, 0, 2},
+		{"custom prefixes are ignored", []*model.OutboundSubscription{mk(1, "hk-"), mk(2, "jp-")}, 0, 1},
+		{"excludes the edited subscription", []*model.OutboundSubscription{mk(5, "sub2-")}, 5, 1},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := defaultPrefixNumber(c.subs, c.excludeId); got != c.want {
+				t.Fatalf("got %d, want %d", got, c.want)
+			}
+		})
+	}
+}
+
+func TestAssignStableTags(t *testing.T) {
+	t.Run("reuses the tag mapped to a known identity", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
+		prev := map[string]string{"id-abc": "sub1-keepme"}
+		got := assignStableTags(parsed, []string{"id-abc"}, prev, nil, 1, "")
+		if got[0] != "sub1-keepme" {
+			t.Fatalf("got %q, want sub1-keepme", got[0])
+		}
+		if parsed[0]["tag"] != "sub1-keepme" {
+			t.Fatalf("tag was not written back into the outbound: %v", parsed[0]["tag"])
+		}
+	})
+
+	t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
+		got := assignStableTags(parsed, []string{"id-new"}, map[string]string{}, map[int]string{0: "sub1-oldpos"}, 1, "")
+		if got[0] != "sub1-oldpos" {
+			t.Fatalf("got %q, want sub1-oldpos", got[0])
+		}
+	})
+
+	t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "Tokyo"}}
+		got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")
+		want := link.SuggestTag("sub7-", "Tokyo", 0)
+		if got[0] != want {
+			t.Fatalf("got %q, want %q", got[0], want)
+		}
+	})
+
+	t.Run("uses a custom prefix for fresh tags", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "Tokyo"}}
+		got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 1, "hk-")
+		want := link.SuggestTag("hk-", "Tokyo", 0)
+		if got[0] != want {
+			t.Fatalf("got %q, want %q", got[0], want)
+		}
+	})
+
+	t.Run("disambiguates colliding tags with a -N suffix", func(t *testing.T) {
+		parsed := []link.Outbound{{"tag": "Same"}, {"tag": "Same"}}
+		got := assignStableTags(parsed, []string{"id1", "id2"}, nil, nil, 1, "p-")
+		base := link.SuggestTag("p-", "Same", 0)
+		if got[0] != base {
+			t.Fatalf("got[0] = %q, want %q", got[0], base)
+		}
+		if got[1] != base+"-1" {
+			t.Fatalf("got[1] = %q, want %q", got[1], base+"-1")
+		}
+	})
+}
+
+// TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
+// when fetching subscription URLs. All rejected cases use literal IPs or bad
+// schemes so the test never performs real DNS resolution.
+func TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes(t *testing.T) {
+	rejected := []string{
+		"http://127.0.0.1/sub",                    // loopback
+		"http://10.0.0.1/x",                       // private
+		"http://192.168.1.1",                      // private
+		"http://169.254.169.254/latest/meta-data", // link-local (cloud metadata)
+		"http://[::1]:8080/sub",                   // IPv6 loopback
+		"http://0.0.0.0",                          // unspecified
+		"ftp://example.com/x",                     // unsupported scheme
+		"file:///etc/passwd",                      // unsupported scheme
+	}
+	for _, raw := range rejected {
+		if _, err := SanitizePublicHTTPURL(raw, false); err == nil {
+			t.Errorf("expected %q to be rejected, got nil error", raw)
+		}
+	}
+
+	t.Run("allows a public literal IP without DNS", func(t *testing.T) {
+		got, err := SanitizePublicHTTPURL("http://8.8.8.8/sub", false)
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if got != "http://8.8.8.8/sub" {
+			t.Fatalf("got %q, want http://8.8.8.8/sub", got)
+		}
+	})
+}

+ 42 - 0
web/service/xray.go

@@ -254,9 +254,51 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		inboundConfig := inbound.GenXrayInboundConfig()
 		inboundConfig := inbound.GenXrayInboundConfig()
 		xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
 		xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
 	}
 	}
+
+	// Merge subscription-derived outbounds (if any) into the final outbounds array.
+	// These are additive: each subscription is placed before or after the template
+	// outbounds based on its Prepend flag, ordered by Priority. Tags assigned by the
+	// subscription service are kept stable across refreshes so that balancers and
+	// routing rules continue to work.
+	subSvc := &OutboundSubscriptionService{}
+	if prepend, appendList, err := subSvc.activeOutboundsSplit(); err == nil && (len(prepend) > 0 || len(appendList) > 0) {
+		mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
+	}
+
 	return xrayConfig, nil
 	return xrayConfig, nil
 }
 }
 
 
+// mergeSubscriptionOutbounds appends the subscription outbounds to the
+// OutboundConfigs array of the xray config. It works on the already-unmarshaled
+// template so that manually configured outbounds are never overwritten.
+//
+// Safety: if we cannot parse the template's outbounds array, we leave
+// OutboundConfigs exactly as it came from the template (we do not inject
+// subscription outbounds). This prevents us from accidentally dropping the
+// user's manually configured outbounds when the template is in a weird state.
+func mergeSubscriptionOutbounds(cfg *xray.Config, prepend, appendList []any) {
+	if len(prepend) == 0 && len(appendList) == 0 {
+		return
+	}
+	var templateOutbounds []any
+	if len(cfg.OutboundConfigs) > 0 {
+		if err := json.Unmarshal(cfg.OutboundConfigs, &templateOutbounds); err != nil {
+			// Corrupt template outbounds — do not touch the field at all.
+			// The user will see problems on Xray start / next save.
+			return
+		}
+	}
+	merged := make([]any, 0, len(prepend)+len(templateOutbounds)+len(appendList))
+	merged = append(merged, prepend...)
+	merged = append(merged, templateOutbounds...)
+	merged = append(merged, appendList...)
+	combined, err := json.MarshalIndent(merged, "", "  ")
+	if err != nil {
+		return
+	}
+	cfg.OutboundConfigs = json_util.RawMessage(combined)
+}
+
 // resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
 // resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
 // absolute paths under config.GetLogFolder(), so Xray writes those files
 // absolute paths under config.GetLogFolder(), so Xray writes those files
 // alongside the panel's other logs regardless of the working directory the
 // alongside the panel's other logs regardless of the working directory the

+ 52 - 0
web/translation/ar-EG.json

@@ -1356,6 +1356,58 @@
         "privateKey": "المفتاح الخاص",
         "privateKey": "المفتاح الخاص",
         "load": "الحمل"
         "load": "الحمل"
       },
       },
+      "OutboundSubscriptions": "اشتراكات الصادرات",
+      "OutboundSubscriptionsDesc": "استورد الصادرات من روابط اشتراك بعيدة (vmess/vless/trojan/ss/...). الوسوم بتفضل ثابتة عشان تستخدمها في موازنات التحميل وقواعد التوجيه. التحديثات بتتم تلقائياً.",
+      "outboundSub": {
+        "manage": "الاشتراكات",
+        "title": "اشتراكات الصادرات",
+        "remark": "ملاحظة (اختياري)",
+        "remarkPlaceholder": "مثلاً نودز هونج كونج",
+        "url": "رابط الاشتراك",
+        "urlPlaceholder": "https://... (قائمة روابط بصيغة base64)",
+        "tagPrefix": "بادئة الوسم",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "فاصل التحديث",
+        "hours": "س",
+        "minutes": "د",
+        "intervalHint": "الافتراضي 10 دقايق. المهمة اللي بتشتغل في الخلفية بتشيك بشكل متكرر؛ كل اشتراك بيعيد الجلب لما يعدّي الفاصل الخاص بيه بس.",
+        "enabled": "مفعّل",
+        "allowPrivate": "السماح بالعناوين الخاصة",
+        "allowPrivateHint": "اسمح بعناوين localhost / الشبكة المحلية (LAN) / عناوين IP الخاصة لرابط الاشتراك ده. متعطّل افتراضياً لدواعي الأمان — فعّله بس لو المصدر المحلي موثوق.",
+        "prepend": "قبل الصادرات اليدوية",
+        "prependHint": "حُط صادرات الاشتراك ده قبل الصادرات اللي ضبطتها بإيدك، عشان واحد منها يقدر يبقى الافتراضي.",
+        "preview": "معاينة",
+        "previewEmpty": "مفيش صادرات على الرابط ده.",
+        "refreshAll": "حدّث الكل",
+        "statusOk": "تمام",
+        "toastUpdated": "تم تحديث الاشتراك",
+        "addButton": "إضافة",
+        "active": "الاشتراكات النشطة",
+        "empty": "مفيش اشتراكات لسه. أضف واحد من فوق.",
+        "colRemark": "ملاحظة",
+        "colPrefix": "بادئة",
+        "colInterval": "الفاصل",
+        "colLastFetch": "آخر جلب",
+        "colEnabled": "مفعّل",
+        "auto": "تلقائي",
+        "never": "أبداً",
+        "yes": "نعم",
+        "no": "لا",
+        "refreshNow": "حدّث الآن",
+        "lastError": "آخر خطأ",
+        "deleteConfirm": "تحذف الاشتراك ده؟",
+        "restartHint": "بعد الإضافة أو التحديث، أعد تشغيل Xray (أو استنى إعادة التحميل التلقائي اللي جاية) عشان تفعّل الصادرات.",
+        "fromSubsTitle": "من اشتراكات الصادرات (للقراءة فقط)",
+        "fromSubsDesc": "مستوردة من اشتراكاتك النشطة. تقدر تديرها من لوحة الاشتراكات اللي فوق.",
+        "toastLoadFailed": "فشل تحميل الاشتراكات",
+        "toastUrlRequired": "رابط الاشتراك مطلوب",
+        "toastAdded": "تمت إضافة الاشتراك",
+        "toastAddFailed": "فشلت إضافة الاشتراك",
+        "toastRefreshed": "تم التحديث",
+        "toastRefreshFailed": "فشل التحديث",
+        "toastDeleted": "تم الحذف",
+        "toastDeleteFailed": "فشل الحذف"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "أضف موازن تحميل",
         "addBalancer": "أضف موازن تحميل",
         "editBalancer": "عدل موازن التحميل",
         "editBalancer": "عدل موازن التحميل",

+ 52 - 0
web/translation/en-US.json

@@ -1199,6 +1199,8 @@
       "Inbounds": "Inbounds",
       "Inbounds": "Inbounds",
       "InboundsDesc": "Accepting the specific clients.",
       "InboundsDesc": "Accepting the specific clients.",
       "Outbounds": "Outbounds",
       "Outbounds": "Outbounds",
+      "OutboundSubscriptions": "Outbound Subscriptions",
+      "OutboundSubscriptionsDesc": "Import outbounds from remote subscription URLs (vmess/vless/trojan/ss/...). Tags are kept stable for use in balancers and routing rules. Updates are automatic.",
       "Balancers": "Balancers",
       "Balancers": "Balancers",
       "balancerTagRequired": "Tag is required",
       "balancerTagRequired": "Tag is required",
       "balancerSelectorRequired": "Pick at least one outbound",
       "balancerSelectorRequired": "Pick at least one outbound",
@@ -1357,6 +1359,56 @@
         "privateKey": "Private Key",
         "privateKey": "Private Key",
         "load": "Load"
         "load": "Load"
       },
       },
+      "outboundSub": {
+        "manage": "Subscriptions",
+        "title": "Outbound Subscriptions",
+        "remark": "Remark (optional)",
+        "remarkPlaceholder": "e.g. HK nodes",
+        "url": "Subscription URL",
+        "urlPlaceholder": "https://... (base64 list of links)",
+        "tagPrefix": "Tag prefix",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Update interval",
+        "hours": "h",
+        "minutes": "min",
+        "intervalHint": "Default 10 minutes. The background job checks frequently; each subscription only re-fetches when its own interval has passed.",
+        "enabled": "Enabled",
+        "allowPrivate": "Allow private address",
+        "allowPrivateHint": "Permit localhost / LAN / private IPs for this subscription's URL. Off by default for security — enable only for a trusted local source.",
+        "prepend": "Before manual outbounds",
+        "prependHint": "Place this subscription's outbounds before your manual ones, so one can become the default.",
+        "preview": "Preview",
+        "previewEmpty": "No outbounds found at this URL.",
+        "refreshAll": "Refresh all",
+        "statusOk": "OK",
+        "toastUpdated": "Subscription updated",
+        "addButton": "Add",
+        "active": "Active subscriptions",
+        "empty": "No subscriptions yet. Add one above.",
+        "colRemark": "Remark",
+        "colPrefix": "Prefix",
+        "colInterval": "Interval",
+        "colLastFetch": "Last fetch",
+        "colEnabled": "Enabled",
+        "auto": "auto",
+        "never": "never",
+        "yes": "Yes",
+        "no": "No",
+        "refreshNow": "Refresh now",
+        "lastError": "Last error",
+        "deleteConfirm": "Delete this subscription?",
+        "restartHint": "After adding or refreshing, restart Xray (or wait for the next auto-reload) to make the outbounds active.",
+        "fromSubsTitle": "From outbound subscriptions (read-only)",
+        "fromSubsDesc": "Imported from your active subscriptions. Manage them in the Subscriptions panel above.",
+        "toastLoadFailed": "Failed to load subscriptions",
+        "toastUrlRequired": "Subscription URL is required",
+        "toastAdded": "Subscription added",
+        "toastAddFailed": "Failed to add subscription",
+        "toastRefreshed": "Refreshed",
+        "toastRefreshFailed": "Refresh failed",
+        "toastDeleted": "Deleted",
+        "toastDeleteFailed": "Delete failed"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Add Balancer",
         "addBalancer": "Add Balancer",
         "editBalancer": "Edit Balancer",
         "editBalancer": "Edit Balancer",

+ 52 - 0
web/translation/es-ES.json

@@ -1356,6 +1356,58 @@
         "privateKey": "Clave privada",
         "privateKey": "Clave privada",
         "load": "Carga"
         "load": "Carga"
       },
       },
+      "OutboundSubscriptions": "Suscripciones de salida",
+      "OutboundSubscriptionsDesc": "Importa salidas desde URLs de suscripción remotas (vmess/vless/trojan/ss/...). Las etiquetas se mantienen estables para usarlas en balanceadores y reglas de enrutamiento. Las actualizaciones son automáticas.",
+      "outboundSub": {
+        "manage": "Suscripciones",
+        "title": "Suscripciones de salida",
+        "remark": "Notas (opcional)",
+        "remarkPlaceholder": "p. ej. nodos HK",
+        "url": "URL de suscripción",
+        "urlPlaceholder": "https://... (lista de enlaces en base64)",
+        "tagPrefix": "Prefijo de etiqueta",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Intervalo de actualización",
+        "hours": "h",
+        "minutes": "min",
+        "intervalHint": "Por defecto 10 minutos. La tarea en segundo plano comprueba con frecuencia; cada suscripción solo vuelve a descargarse cuando ha transcurrido su propio intervalo.",
+        "enabled": "Habilitado",
+        "allowPrivate": "Permitir direcciones privadas",
+        "allowPrivateHint": "Permite localhost, la red local (LAN) y las IP privadas en la URL de esta suscripción. Desactivado por defecto por seguridad; actívalo solo para una fuente local de confianza.",
+        "prepend": "Antes de las salidas manuales",
+        "prependHint": "Coloca las salidas de esta suscripción antes de las configuradas manualmente, de modo que una de ellas pueda convertirse en la predeterminada.",
+        "preview": "Vista previa",
+        "previewEmpty": "No se encontraron salidas en esta URL.",
+        "refreshAll": "Actualizar todo",
+        "statusOk": "Correcto",
+        "toastUpdated": "Suscripción actualizada",
+        "addButton": "Añadir",
+        "active": "Suscripciones activas",
+        "empty": "Aún no hay suscripciones. Añade una arriba.",
+        "colRemark": "Notas",
+        "colPrefix": "Prefijo",
+        "colInterval": "Intervalo",
+        "colLastFetch": "Última descarga",
+        "colEnabled": "Habilitado",
+        "auto": "auto",
+        "never": "nunca",
+        "yes": "Sí",
+        "no": "No",
+        "refreshNow": "Actualizar ahora",
+        "lastError": "Último error",
+        "deleteConfirm": "¿Eliminar esta suscripción?",
+        "restartHint": "Después de añadir o actualizar, reinicia Xray (o espera a la próxima recarga automática) para activar las salidas.",
+        "fromSubsTitle": "Desde suscripciones de salida (solo lectura)",
+        "fromSubsDesc": "Importadas desde tus suscripciones activas. Gestiónalas en el panel de Suscripciones de arriba.",
+        "toastLoadFailed": "No se pudieron cargar las suscripciones",
+        "toastUrlRequired": "La URL de suscripción es obligatoria",
+        "toastAdded": "Suscripción añadida",
+        "toastAddFailed": "No se pudo añadir la suscripción",
+        "toastRefreshed": "Actualizada",
+        "toastRefreshFailed": "Error al actualizar",
+        "toastDeleted": "Eliminada",
+        "toastDeleteFailed": "Error al eliminar"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Agregar equilibrador",
         "addBalancer": "Agregar equilibrador",
         "editBalancer": "Editar balanceador",
         "editBalancer": "Editar balanceador",

+ 52 - 0
web/translation/fa-IR.json

@@ -1356,6 +1356,58 @@
         "privateKey": "کلید خصوصی",
         "privateKey": "کلید خصوصی",
         "load": "فشار سرور"
         "load": "فشار سرور"
       },
       },
+      "OutboundSubscriptions": "سابسکریپشن‌های خروجی",
+      "OutboundSubscriptionsDesc": "خروجی‌ها را از آدرس‌های سابسکریپشن راه‌دور (vmess/vless/trojan/ss/...) وارد کنید. تگ‌ها ثابت می‌مانند تا در بالانسرها و قوانین مسیریابی قابل استفاده باشند. به‌روزرسانی‌ها به‌صورت خودکار انجام می‌شوند.",
+      "outboundSub": {
+        "manage": "سابسکریپشن‌ها",
+        "title": "سابسکریپشن‌های خروجی",
+        "remark": "نام (اختیاری)",
+        "remarkPlaceholder": "مثلاً نودهای هنگ‌کنگ",
+        "url": "آدرس سابسکریپشن",
+        "urlPlaceholder": "https://... (فهرست base64 از لینک‌ها)",
+        "tagPrefix": "پیشوند تگ",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "بازه به‌روزرسانی",
+        "hours": "ساعت",
+        "minutes": "دقیقه",
+        "intervalHint": "پیش‌فرض ۱۰ دقیقه. وظیفهٔ پس‌زمینه به‌طور مکرر بررسی می‌کند؛ هر سابسکریپشن فقط زمانی دوباره دریافت می‌شود که بازهٔ خودش سپری شده باشد.",
+        "enabled": "فعال",
+        "allowPrivate": "اجازهٔ آدرس خصوصی",
+        "allowPrivateHint": "اجازه به localhost / شبکهٔ محلی (LAN) / IPهای خصوصی برای آدرس این سابسکریپشن. به‌دلایل امنیتی به‌طور پیش‌فرض غیرفعال است؛ فقط برای یک منبع محلی مورد اعتماد فعال کنید.",
+        "prepend": "پیش از خروجی‌های دستی",
+        "prependHint": "خروجی‌های این سابسکریپشن را پیش از خروجی‌های دستی شما قرار می‌دهد تا یکی از آن‌ها بتواند پیش‌فرض شود.",
+        "preview": "پیش‌نمایش",
+        "previewEmpty": "هیچ خروجی‌ای در این آدرس یافت نشد.",
+        "refreshAll": "تازه‌سازی همه",
+        "statusOk": "موفق",
+        "toastUpdated": "سابسکریپشن به‌روزرسانی شد",
+        "addButton": "افزودن",
+        "active": "سابسکریپشن‌های فعال",
+        "empty": "هنوز سابسکریپشنی وجود ندارد. از بالا یکی اضافه کنید.",
+        "colRemark": "نام",
+        "colPrefix": "پیشوند",
+        "colInterval": "بازه",
+        "colLastFetch": "آخرین دریافت",
+        "colEnabled": "فعال",
+        "auto": "خودکار",
+        "never": "هرگز",
+        "yes": "بله",
+        "no": "خیر",
+        "refreshNow": "تازه‌سازی اکنون",
+        "lastError": "آخرین خطا",
+        "deleteConfirm": "این سابسکریپشن حذف شود؟",
+        "restartHint": "پس از افزودن یا تازه‌سازی، برای فعال‌شدن خروجی‌ها Xray را راه‌اندازی مجدد کنید (یا منتظر بارگذاری مجدد خودکار بعدی بمانید).",
+        "fromSubsTitle": "از سابسکریپشن‌های خروجی (فقط‌خواندنی)",
+        "fromSubsDesc": "از سابسکریپشن‌های فعال شما وارد شده‌اند. آن‌ها را از پنل سابسکریپشن‌ها در بالا مدیریت کنید.",
+        "toastLoadFailed": "بارگذاری سابسکریپشن‌ها ناموفق بود",
+        "toastUrlRequired": "آدرس سابسکریپشن الزامی است",
+        "toastAdded": "سابسکریپشن افزوده شد",
+        "toastAddFailed": "افزودن سابسکریپشن ناموفق بود",
+        "toastRefreshed": "تازه‌سازی شد",
+        "toastRefreshFailed": "تازه‌سازی ناموفق بود",
+        "toastDeleted": "حذف شد",
+        "toastDeleteFailed": "حذف ناموفق بود"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "افزودن بالانسر",
         "addBalancer": "افزودن بالانسر",
         "editBalancer": "ویرایش بالانسر",
         "editBalancer": "ویرایش بالانسر",

+ 52 - 0
web/translation/id-ID.json

@@ -1356,6 +1356,58 @@
         "privateKey": "Kunci Privat",
         "privateKey": "Kunci Privat",
         "load": "Beban"
         "load": "Beban"
       },
       },
+      "OutboundSubscriptions": "Langganan Outbound",
+      "OutboundSubscriptionsDesc": "Impor outbound dari URL langganan jarak jauh (vmess/vless/trojan/ss/...). Tag dijaga tetap stabil untuk digunakan pada penyeimbang dan aturan routing. Pembaruan berjalan otomatis.",
+      "outboundSub": {
+        "manage": "Langganan",
+        "title": "Langganan Outbound",
+        "remark": "Catatan (opsional)",
+        "remarkPlaceholder": "mis. node HK",
+        "url": "URL langganan",
+        "urlPlaceholder": "https://... (daftar tautan base64)",
+        "tagPrefix": "Awalan tag",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Interval pembaruan",
+        "hours": "j",
+        "minutes": "mnt",
+        "intervalHint": "Default 10 menit. Tugas latar belakang memeriksa secara berkala; setiap langganan hanya diambil ulang ketika intervalnya sendiri telah terlewati.",
+        "enabled": "Aktif",
+        "allowPrivate": "Izinkan alamat privat",
+        "allowPrivateHint": "Izinkan localhost / LAN / IP privat untuk URL langganan ini. Nonaktif secara default demi keamanan — aktifkan hanya untuk sumber lokal yang tepercaya.",
+        "prepend": "Sebelum outbound manual",
+        "prependHint": "Tempatkan outbound dari langganan ini sebelum outbound manual Anda, sehingga salah satunya dapat menjadi default.",
+        "preview": "Pratinjau",
+        "previewEmpty": "Tidak ada outbound yang ditemukan di URL ini.",
+        "refreshAll": "Segarkan semua",
+        "statusOk": "OK",
+        "toastUpdated": "Langganan diperbarui",
+        "addButton": "Tambah",
+        "active": "Langganan aktif",
+        "empty": "Belum ada langganan. Tambahkan satu di atas.",
+        "colRemark": "Catatan",
+        "colPrefix": "Awalan",
+        "colInterval": "Interval",
+        "colLastFetch": "Pengambilan terakhir",
+        "colEnabled": "Aktif",
+        "auto": "otomatis",
+        "never": "tidak pernah",
+        "yes": "Ya",
+        "no": "Tidak",
+        "refreshNow": "Segarkan sekarang",
+        "lastError": "Kesalahan terakhir",
+        "deleteConfirm": "Hapus langganan ini?",
+        "restartHint": "Setelah menambahkan atau menyegarkan, mulai ulang Xray (atau tunggu muat ulang otomatis berikutnya) agar outbound menjadi aktif.",
+        "fromSubsTitle": "Dari langganan outbound (hanya-baca)",
+        "fromSubsDesc": "Diimpor dari langganan aktif Anda. Kelola di panel Langganan di atas.",
+        "toastLoadFailed": "Gagal memuat langganan",
+        "toastUrlRequired": "URL langganan wajib diisi",
+        "toastAdded": "Langganan ditambahkan",
+        "toastAddFailed": "Gagal menambahkan langganan",
+        "toastRefreshed": "Disegarkan",
+        "toastRefreshFailed": "Gagal menyegarkan",
+        "toastDeleted": "Dihapus",
+        "toastDeleteFailed": "Gagal menghapus"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Tambahkan Penyeimbang",
         "addBalancer": "Tambahkan Penyeimbang",
         "editBalancer": "Sunting Penyeimbang",
         "editBalancer": "Sunting Penyeimbang",

+ 52 - 0
web/translation/ja-JP.json

@@ -1356,6 +1356,58 @@
         "privateKey": "秘密鍵",
         "privateKey": "秘密鍵",
         "load": "負荷"
         "load": "負荷"
       },
       },
+      "OutboundSubscriptions": "アウトバウンドサブスクリプション",
+      "OutboundSubscriptionsDesc": "リモートのサブスクリプションURL(vmess/vless/trojan/ss/...)からアウトバウンドをインポートします。タグはバランサーやルーティングルールで使えるように安定して保持されます。更新は自動的に行われます。",
+      "outboundSub": {
+        "manage": "サブスクリプション",
+        "title": "アウトバウンドサブスクリプション",
+        "remark": "備考(任意)",
+        "remarkPlaceholder": "例: 香港ノード",
+        "url": "サブスクリプションURL",
+        "urlPlaceholder": "https://...(リンクのbase64リスト)",
+        "tagPrefix": "タグのプレフィックス",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "更新間隔",
+        "hours": "時間",
+        "minutes": "分",
+        "intervalHint": "デフォルトは10分です。バックグラウンドジョブは頻繁にチェックしますが、各サブスクリプションは自身の間隔が経過したときにのみ再取得されます。",
+        "enabled": "有効",
+        "allowPrivate": "プライベートアドレスを許可",
+        "allowPrivateHint": "このサブスクリプションのURLに対して、localhost・LAN・プライベートIPへのアクセスを許可します。セキュリティのため既定では無効です。信頼できるローカルソースの場合のみ有効にしてください。",
+        "prepend": "手動アウトバウンドの前に配置",
+        "prependHint": "このサブスクリプションのアウトバウンドを、手動で設定したアウトバウンドより前に配置します。これにより、いずれかをデフォルトにできます。",
+        "preview": "プレビュー",
+        "previewEmpty": "このURLにはアウトバウンドが見つかりませんでした。",
+        "refreshAll": "すべて更新",
+        "statusOk": "OK",
+        "toastUpdated": "サブスクリプションを更新しました",
+        "addButton": "追加",
+        "active": "有効なサブスクリプション",
+        "empty": "サブスクリプションはまだありません。上から追加してください。",
+        "colRemark": "備考",
+        "colPrefix": "プレフィックス",
+        "colInterval": "間隔",
+        "colLastFetch": "最終取得",
+        "colEnabled": "有効",
+        "auto": "自動",
+        "never": "なし",
+        "yes": "はい",
+        "no": "いいえ",
+        "refreshNow": "今すぐ更新",
+        "lastError": "最後のエラー",
+        "deleteConfirm": "このサブスクリプションを削除しますか?",
+        "restartHint": "追加または更新した後、アウトバウンドを有効にするにはXrayを再起動してください(または次の自動リロードをお待ちください)。",
+        "fromSubsTitle": "アウトバウンドサブスクリプションから(読み取り専用)",
+        "fromSubsDesc": "有効なサブスクリプションからインポートされています。上のサブスクリプションパネルで管理してください。",
+        "toastLoadFailed": "サブスクリプションの読み込みに失敗しました",
+        "toastUrlRequired": "サブスクリプションURLは必須です",
+        "toastAdded": "サブスクリプションを追加しました",
+        "toastAddFailed": "サブスクリプションの追加に失敗しました",
+        "toastRefreshed": "更新しました",
+        "toastRefreshFailed": "更新に失敗しました",
+        "toastDeleted": "削除しました",
+        "toastDeleteFailed": "削除に失敗しました"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "負荷分散追加",
         "addBalancer": "負荷分散追加",
         "editBalancer": "負荷分散編集",
         "editBalancer": "負荷分散編集",

+ 52 - 0
web/translation/pt-BR.json

@@ -1356,6 +1356,58 @@
         "privateKey": "Chave Privada",
         "privateKey": "Chave Privada",
         "load": "Carga"
         "load": "Carga"
       },
       },
+      "OutboundSubscriptions": "Assinaturas de Saída",
+      "OutboundSubscriptionsDesc": "Importe saídas a partir de URLs de assinatura remotas (vmess/vless/trojan/ss/...). As tags são mantidas estáveis para uso em balanceadores e regras de roteamento. As atualizações são automáticas.",
+      "outboundSub": {
+        "manage": "Assinaturas",
+        "title": "Assinaturas de Saída",
+        "remark": "Observação (opcional)",
+        "remarkPlaceholder": "ex.: nós de HK",
+        "url": "URL da assinatura",
+        "urlPlaceholder": "https://... (lista de links em base64)",
+        "tagPrefix": "Prefixo da tag",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Intervalo de atualização",
+        "hours": "h",
+        "minutes": "min",
+        "intervalHint": "Padrão de 10 minutos. A tarefa em segundo plano verifica com frequência; cada assinatura só é buscada novamente quando o seu próprio intervalo é atingido.",
+        "enabled": "Ativado",
+        "allowPrivate": "Permitir endereço privado",
+        "allowPrivateHint": "Permite localhost / LAN / IPs privados para a URL desta assinatura. Desativado por padrão por segurança — ative apenas para uma fonte local confiável.",
+        "prepend": "Antes das saídas manuais",
+        "prependHint": "Coloca as saídas desta assinatura antes das suas saídas configuradas manualmente, para que uma delas possa se tornar a padrão.",
+        "preview": "Pré-visualizar",
+        "previewEmpty": "Nenhuma saída encontrada nesta URL.",
+        "refreshAll": "Atualizar todas",
+        "statusOk": "OK",
+        "toastUpdated": "Assinatura atualizada",
+        "addButton": "Adicionar",
+        "active": "Assinaturas ativas",
+        "empty": "Nenhuma assinatura ainda. Adicione uma acima.",
+        "colRemark": "Observação",
+        "colPrefix": "Prefixo",
+        "colInterval": "Intervalo",
+        "colLastFetch": "Última busca",
+        "colEnabled": "Ativado",
+        "auto": "auto",
+        "never": "nunca",
+        "yes": "Sim",
+        "no": "Não",
+        "refreshNow": "Atualizar agora",
+        "lastError": "Último erro",
+        "deleteConfirm": "Excluir esta assinatura?",
+        "restartHint": "Após adicionar ou atualizar, reinicie o Xray (ou aguarde o próximo recarregamento automático) para ativar as saídas.",
+        "fromSubsTitle": "De assinaturas de saída (somente leitura)",
+        "fromSubsDesc": "Importadas das suas assinaturas ativas. Gerencie-as no painel de Assinaturas acima.",
+        "toastLoadFailed": "Falha ao carregar as assinaturas",
+        "toastUrlRequired": "A URL da assinatura é obrigatória",
+        "toastAdded": "Assinatura adicionada",
+        "toastAddFailed": "Falha ao adicionar a assinatura",
+        "toastRefreshed": "Atualizado",
+        "toastRefreshFailed": "Falha na atualização",
+        "toastDeleted": "Excluído",
+        "toastDeleteFailed": "Falha ao excluir"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Adicionar Balanceador",
         "addBalancer": "Adicionar Balanceador",
         "editBalancer": "Editar Balanceador",
         "editBalancer": "Editar Balanceador",

+ 52 - 0
web/translation/ru-RU.json

@@ -1356,6 +1356,58 @@
         "privateKey": "Приватный ключ",
         "privateKey": "Приватный ключ",
         "load": "Нагрузка"
         "load": "Нагрузка"
       },
       },
+      "OutboundSubscriptions": "Подписки исходящих",
+      "OutboundSubscriptionsDesc": "Импорт исходящих из удалённых URL подписок (vmess/vless/trojan/ss/...). Теги остаются неизменными для использования в балансировщиках и правилах маршрутизации. Обновление выполняется автоматически.",
+      "outboundSub": {
+        "manage": "Подписки",
+        "title": "Подписки исходящих",
+        "remark": "Примечание (необязательно)",
+        "remarkPlaceholder": "напр. узлы HK",
+        "url": "URL подписки",
+        "urlPlaceholder": "https://... (список ссылок в base64)",
+        "tagPrefix": "Префикс тега",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Интервал обновления",
+        "hours": "ч",
+        "minutes": "мин",
+        "intervalHint": "По умолчанию 10 минут. Фоновая задача проверяет подписки часто, но каждая подписка обновляется только после того, как пройдёт её собственный интервал.",
+        "enabled": "Включено",
+        "allowPrivate": "Разрешить приватные адреса",
+        "allowPrivateHint": "Разрешить localhost, LAN и приватные IP-адреса для URL этой подписки. По умолчанию отключено в целях безопасности — включайте только для доверенного локального источника.",
+        "prepend": "Перед ручными исходящими",
+        "prependHint": "Размещать исходящие этой подписки перед вашими настроенными вручную, чтобы один из них мог стать исходящим по умолчанию.",
+        "preview": "Предпросмотр",
+        "previewEmpty": "По этому URL исходящих не найдено.",
+        "refreshAll": "Обновить все",
+        "statusOk": "OK",
+        "toastUpdated": "Подписка обновлена",
+        "addButton": "Добавить",
+        "active": "Активные подписки",
+        "empty": "Подписок пока нет. Добавьте одну выше.",
+        "colRemark": "Примечание",
+        "colPrefix": "Префикс",
+        "colInterval": "Интервал",
+        "colLastFetch": "Последнее обновление",
+        "colEnabled": "Включено",
+        "auto": "авто",
+        "never": "никогда",
+        "yes": "Да",
+        "no": "Нет",
+        "refreshNow": "Обновить сейчас",
+        "lastError": "Последняя ошибка",
+        "deleteConfirm": "Удалить эту подписку?",
+        "restartHint": "После добавления или обновления перезапустите Xray (или дождитесь следующей автоперезагрузки), чтобы исходящие стали активными.",
+        "fromSubsTitle": "Из подписок исходящих (только для чтения)",
+        "fromSubsDesc": "Импортировано из ваших активных подписок. Управление ими доступно на панели «Подписки» выше.",
+        "toastLoadFailed": "Не удалось загрузить подписки",
+        "toastUrlRequired": "Укажите URL подписки",
+        "toastAdded": "Подписка добавлена",
+        "toastAddFailed": "Не удалось добавить подписку",
+        "toastRefreshed": "Обновлено",
+        "toastRefreshFailed": "Не удалось обновить",
+        "toastDeleted": "Удалено",
+        "toastDeleteFailed": "Не удалось удалить"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Создать балансировщик",
         "addBalancer": "Создать балансировщик",
         "editBalancer": "Редактировать балансировщик",
         "editBalancer": "Редактировать балансировщик",

+ 52 - 0
web/translation/tr-TR.json

@@ -1356,6 +1356,58 @@
         "privateKey": "Özel Anahtar",
         "privateKey": "Özel Anahtar",
         "load": "Yük"
         "load": "Yük"
       },
       },
+      "OutboundSubscriptions": "Çıkış Abonelikleri",
+      "OutboundSubscriptionsDesc": "Uzak abonelik URL'lerinden (vmess/vless/trojan/ss/...) çıkış noktalarını içe aktarın. Etiketler, dengeleyiciler ve yönlendirme kurallarında kullanılmak üzere sabit tutulur. Güncellemeler otomatiktir.",
+      "outboundSub": {
+        "manage": "Abonelikler",
+        "title": "Çıkış Abonelikleri",
+        "remark": "Açıklama (isteğe bağlı)",
+        "remarkPlaceholder": "örn. HK düğümleri",
+        "url": "Abonelik URL'si",
+        "urlPlaceholder": "https://... (bağlantıların base64 listesi)",
+        "tagPrefix": "Etiket öneki",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Güncelleme aralığı",
+        "hours": "sa",
+        "minutes": "dk",
+        "intervalHint": "Varsayılan 10 dakika. Arka plan görevi sık sık denetler; her abonelik yalnızca kendi aralığı dolduğunda yeniden indirir.",
+        "enabled": "Etkin",
+        "allowPrivate": "Özel adrese izin ver",
+        "allowPrivateHint": "Bu aboneliğin URL'si için localhost / LAN / özel IP'lere izin verir. Güvenlik nedeniyle varsayılan olarak kapalıdır; yalnızca güvenilir bir yerel kaynak için etkinleştirin.",
+        "prepend": "Manuel çıkışlardan önce",
+        "prependHint": "Bu aboneliğin çıkışlarını manuel olarak eklediklerinizden önce yerleştirir; böylece bunlardan biri varsayılan olabilir.",
+        "preview": "Önizleme",
+        "previewEmpty": "Bu URL'de çıkış bulunamadı.",
+        "refreshAll": "Tümünü yenile",
+        "statusOk": "Tamam",
+        "toastUpdated": "Abonelik güncellendi",
+        "addButton": "Ekle",
+        "active": "Aktif abonelikler",
+        "empty": "Henüz abonelik yok. Yukarıdan bir tane ekleyin.",
+        "colRemark": "Açıklama",
+        "colPrefix": "Önek",
+        "colInterval": "Aralık",
+        "colLastFetch": "Son indirme",
+        "colEnabled": "Etkin",
+        "auto": "otomatik",
+        "never": "asla",
+        "yes": "Evet",
+        "no": "Hayır",
+        "refreshNow": "Şimdi yenile",
+        "lastError": "Son hata",
+        "deleteConfirm": "Bu abonelik silinsin mi?",
+        "restartHint": "Ekledikten veya yeniledikten sonra, çıkış noktalarını etkinleştirmek için Xray'i yeniden başlatın (ya da bir sonraki otomatik yeniden yüklemeyi bekleyin).",
+        "fromSubsTitle": "Çıkış aboneliklerinden (salt okunur)",
+        "fromSubsDesc": "Aktif aboneliklerinizden içe aktarıldı. Bunları yukarıdaki Abonelikler panelinden yönetin.",
+        "toastLoadFailed": "Abonelikler yüklenemedi",
+        "toastUrlRequired": "Abonelik URL'si gerekli",
+        "toastAdded": "Abonelik eklendi",
+        "toastAddFailed": "Abonelik eklenemedi",
+        "toastRefreshed": "Yenilendi",
+        "toastRefreshFailed": "Yenileme başarısız",
+        "toastDeleted": "Silindi",
+        "toastDeleteFailed": "Silme başarısız"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Dengeleyici Ekle",
         "addBalancer": "Dengeleyici Ekle",
         "editBalancer": "Dengeleyiciyi Düzenle",
         "editBalancer": "Dengeleyiciyi Düzenle",

+ 52 - 0
web/translation/uk-UA.json

@@ -1356,6 +1356,58 @@
         "privateKey": "Приватний ключ",
         "privateKey": "Приватний ключ",
         "load": "Навантаження"
         "load": "Навантаження"
       },
       },
+      "OutboundSubscriptions": "Підписки вихідних",
+      "OutboundSubscriptionsDesc": "Імпортуйте вихідні з віддалених URL підписок (vmess/vless/trojan/ss/...). Теги залишаються стабільними для використання в балансувальниках і правилах маршрутизації. Оновлення відбувається автоматично.",
+      "outboundSub": {
+        "manage": "Підписки",
+        "title": "Підписки вихідних",
+        "remark": "Примітка (необов'язково)",
+        "remarkPlaceholder": "напр. вузли HK",
+        "url": "URL підписки",
+        "urlPlaceholder": "https://... (список посилань у base64)",
+        "tagPrefix": "Префікс тегу",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Інтервал оновлення",
+        "hours": "год",
+        "minutes": "хв",
+        "intervalHint": "За замовчуванням 10 хвилин. Фонове завдання перевіряє часто; кожна підписка повторно завантажується лише після того, як мине її власний інтервал.",
+        "enabled": "Увімкнено",
+        "allowPrivate": "Дозволити приватні адреси",
+        "allowPrivateHint": "Дозволити localhost / LAN / приватні IP-адреси для URL цієї підписки. З міркувань безпеки вимкнено за замовчуванням — вмикайте лише для довіреного локального джерела.",
+        "prepend": "Перед ручними вихідними",
+        "prependHint": "Розмістити вихідні цієї підписки перед вашими ручними, щоб один із них міг стати типовим.",
+        "preview": "Попередній перегляд",
+        "previewEmpty": "За цим URL вихідних не знайдено.",
+        "refreshAll": "Оновити всі",
+        "statusOk": "OK",
+        "toastUpdated": "Підписку оновлено",
+        "addButton": "Додати",
+        "active": "Активні підписки",
+        "empty": "Підписок поки немає. Додайте одну вище.",
+        "colRemark": "Примітка",
+        "colPrefix": "Префікс",
+        "colInterval": "Інтервал",
+        "colLastFetch": "Останнє завантаження",
+        "colEnabled": "Увімкнено",
+        "auto": "авто",
+        "never": "ніколи",
+        "yes": "Так",
+        "no": "Ні",
+        "refreshNow": "Оновити зараз",
+        "lastError": "Остання помилка",
+        "deleteConfirm": "Видалити цю підписку?",
+        "restartHint": "Після додавання або оновлення перезапустіть Xray (або зачекайте наступного автоматичного перезавантаження), щоб вихідні стали активними.",
+        "fromSubsTitle": "З підписок вихідних (лише для читання)",
+        "fromSubsDesc": "Імпортовано з ваших активних підписок. Керуйте ними на панелі «Підписки» вище.",
+        "toastLoadFailed": "Не вдалося завантажити підписки",
+        "toastUrlRequired": "Потрібен URL підписки",
+        "toastAdded": "Підписку додано",
+        "toastAddFailed": "Не вдалося додати підписку",
+        "toastRefreshed": "Оновлено",
+        "toastRefreshFailed": "Не вдалося оновити",
+        "toastDeleted": "Видалено",
+        "toastDeleteFailed": "Не вдалося видалити"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Додати балансир",
         "addBalancer": "Додати балансир",
         "editBalancer": "Редагувати балансир",
         "editBalancer": "Редагувати балансир",

+ 52 - 0
web/translation/vi-VN.json

@@ -1356,6 +1356,58 @@
         "privateKey": "Khóa riêng",
         "privateKey": "Khóa riêng",
         "load": "Tải"
         "load": "Tải"
       },
       },
+      "OutboundSubscriptions": "Đăng ký Outbound",
+      "OutboundSubscriptionsDesc": "Nhập các outbound từ URL đăng ký từ xa (vmess/vless/trojan/ss/...). Tag được giữ ổn định để dùng trong bộ cân bằng tải và quy tắc định tuyến. Cập nhật diễn ra tự động.",
+      "outboundSub": {
+        "manage": "Đăng ký",
+        "title": "Đăng ký Outbound",
+        "remark": "Ghi chú (tùy chọn)",
+        "remarkPlaceholder": "ví dụ node HK",
+        "url": "URL đăng ký",
+        "urlPlaceholder": "https://... (danh sách liên kết base64)",
+        "tagPrefix": "Tiền tố tag",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "Khoảng cập nhật",
+        "hours": "giờ",
+        "minutes": "phút",
+        "intervalHint": "Mặc định 10 phút. Tác vụ nền kiểm tra thường xuyên; mỗi đăng ký chỉ tải lại khi khoảng thời gian riêng của nó đã trôi qua.",
+        "enabled": "Đã kích hoạt",
+        "allowPrivate": "Cho phép địa chỉ riêng tư",
+        "allowPrivateHint": "Cho phép localhost / mạng LAN / IP riêng tư đối với URL của đăng ký này. Mặc định tắt vì lý do bảo mật — chỉ bật khi nguồn cục bộ đáng tin cậy.",
+        "prepend": "Trước các outbound thủ công",
+        "prependHint": "Đặt các outbound của đăng ký này trước các outbound bạn cấu hình thủ công, để một trong số đó có thể trở thành mặc định.",
+        "preview": "Xem trước",
+        "previewEmpty": "Không tìm thấy outbound nào tại URL này.",
+        "refreshAll": "Cập nhật tất cả",
+        "statusOk": "OK",
+        "toastUpdated": "Đã cập nhật đăng ký",
+        "addButton": "Thêm",
+        "active": "Đăng ký đang hoạt động",
+        "empty": "Chưa có đăng ký nào. Hãy thêm một mục ở trên.",
+        "colRemark": "Ghi chú",
+        "colPrefix": "Tiền tố",
+        "colInterval": "Khoảng",
+        "colLastFetch": "Lần tải gần nhất",
+        "colEnabled": "Đã kích hoạt",
+        "auto": "tự động",
+        "never": "không bao giờ",
+        "yes": "Có",
+        "no": "Không",
+        "refreshNow": "Cập nhật ngay",
+        "lastError": "Lỗi gần nhất",
+        "deleteConfirm": "Xóa đăng ký này?",
+        "restartHint": "Sau khi thêm hoặc cập nhật, hãy khởi động lại Xray (hoặc chờ lần tự động tải lại tiếp theo) để kích hoạt các outbound.",
+        "fromSubsTitle": "Từ đăng ký outbound (chỉ đọc)",
+        "fromSubsDesc": "Được nhập từ các đăng ký đang hoạt động của bạn. Hãy quản lý chúng trong bảng Đăng ký ở trên.",
+        "toastLoadFailed": "Không thể tải danh sách đăng ký",
+        "toastUrlRequired": "URL đăng ký là bắt buộc",
+        "toastAdded": "Đã thêm đăng ký",
+        "toastAddFailed": "Không thể thêm đăng ký",
+        "toastRefreshed": "Đã cập nhật",
+        "toastRefreshFailed": "Cập nhật thất bại",
+        "toastDeleted": "Đã xóa",
+        "toastDeleteFailed": "Xóa thất bại"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "Thêm cân bằng",
         "addBalancer": "Thêm cân bằng",
         "editBalancer": "Chỉnh sửa cân bằng",
         "editBalancer": "Chỉnh sửa cân bằng",

+ 52 - 0
web/translation/zh-CN.json

@@ -1356,6 +1356,58 @@
         "privateKey": "私钥",
         "privateKey": "私钥",
         "load": "负载"
         "load": "负载"
       },
       },
+      "OutboundSubscriptions": "出站订阅",
+      "OutboundSubscriptionsDesc": "从远程订阅 URL(vmess/vless/trojan/ss/…)导入出站。标签保持稳定,可用于负载均衡器和路由规则。更新会自动进行。",
+      "outboundSub": {
+        "manage": "订阅",
+        "title": "出站订阅",
+        "remark": "备注(可选)",
+        "remarkPlaceholder": "如:香港节点",
+        "url": "订阅 URL",
+        "urlPlaceholder": "https://...(base64 编码的链接列表)",
+        "tagPrefix": "标签前缀",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "更新间隔",
+        "hours": "时",
+        "minutes": "分",
+        "intervalHint": "默认 10 分钟。后台任务会频繁检查;每个订阅仅在自身间隔到期后才重新拉取。",
+        "enabled": "启用",
+        "allowPrivate": "允许私有地址",
+        "allowPrivateHint": "允许此订阅 URL 使用 localhost / 局域网(LAN)/ 私有 IP 地址。出于安全考虑默认关闭,仅在使用可信的本地来源时才开启。",
+        "prepend": "置于手动出站之前",
+        "prependHint": "将此订阅的出站排在手动配置的出站之前,使其中之一可成为默认出站。",
+        "preview": "预览",
+        "previewEmpty": "在该 URL 未找到任何出站。",
+        "refreshAll": "全部刷新",
+        "statusOk": "正常",
+        "toastUpdated": "订阅已更新",
+        "addButton": "添加",
+        "active": "已启用的订阅",
+        "empty": "暂无订阅。请在上方添加。",
+        "colRemark": "备注",
+        "colPrefix": "前缀",
+        "colInterval": "间隔",
+        "colLastFetch": "上次拉取",
+        "colEnabled": "启用",
+        "auto": "自动",
+        "never": "从未",
+        "yes": "是",
+        "no": "否",
+        "refreshNow": "立即刷新",
+        "lastError": "上次错误",
+        "deleteConfirm": "删除此订阅?",
+        "restartHint": "添加或刷新后,请重启 Xray(或等待下一次自动重载)以使出站生效。",
+        "fromSubsTitle": "来自出站订阅(只读)",
+        "fromSubsDesc": "从已启用的订阅中导入。请在上方的订阅面板中管理它们。",
+        "toastLoadFailed": "加载订阅失败",
+        "toastUrlRequired": "订阅 URL 为必填项",
+        "toastAdded": "订阅已添加",
+        "toastAddFailed": "添加订阅失败",
+        "toastRefreshed": "已刷新",
+        "toastRefreshFailed": "刷新失败",
+        "toastDeleted": "已删除",
+        "toastDeleteFailed": "删除失败"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "添加负载均衡",
         "addBalancer": "添加负载均衡",
         "editBalancer": "编辑负载均衡",
         "editBalancer": "编辑负载均衡",

+ 52 - 0
web/translation/zh-TW.json

@@ -1356,6 +1356,58 @@
         "privateKey": "私密金鑰",
         "privateKey": "私密金鑰",
         "load": "負載"
         "load": "負載"
       },
       },
+      "OutboundSubscriptions": "出站訂閱",
+      "OutboundSubscriptionsDesc": "從遠端訂閱 URL(vmess/vless/trojan/ss/...)匯入出站。標籤會保持穩定,以便在負載均衡與路由規則中使用。系統會自動更新。",
+      "outboundSub": {
+        "manage": "訂閱",
+        "title": "出站訂閱",
+        "remark": "備註(選填)",
+        "remarkPlaceholder": "例如:香港節點",
+        "url": "訂閱 URL",
+        "urlPlaceholder": "https://...(base64 連結清單)",
+        "tagPrefix": "標籤前綴",
+        "tagPrefixPlaceholder": "hk-",
+        "interval": "更新間隔",
+        "hours": "時",
+        "minutes": "分",
+        "intervalHint": "預設為 10 分鐘。背景工作會頻繁檢查;每個訂閱只在自己的間隔到期後才重新抓取。",
+        "enabled": "啟用",
+        "allowPrivate": "允許私有位址",
+        "allowPrivateHint": "允許此訂閱的 URL 使用 localhost/區域網路(LAN)/私有 IP。基於安全考量預設為關閉,請僅在來源為受信任的本機時才啟用。",
+        "prepend": "置於手動出站之前",
+        "prependHint": "將此訂閱的出站排在您手動設定的出站之前,讓其中之一可成為預設出站。",
+        "preview": "預覽",
+        "previewEmpty": "在此 URL 找不到任何出站。",
+        "refreshAll": "全部重新整理",
+        "statusOk": "正常",
+        "toastUpdated": "訂閱已更新",
+        "addButton": "新增",
+        "active": "啟用中的訂閱",
+        "empty": "尚無訂閱。請從上方新增。",
+        "colRemark": "備註",
+        "colPrefix": "前綴",
+        "colInterval": "間隔",
+        "colLastFetch": "上次抓取",
+        "colEnabled": "啟用",
+        "auto": "自動",
+        "never": "從不",
+        "yes": "是",
+        "no": "否",
+        "refreshNow": "立即重新整理",
+        "lastError": "上次錯誤",
+        "deleteConfirm": "確定要刪除此訂閱嗎?",
+        "restartHint": "新增或重新整理後,請重新啟動 Xray(或等待下次自動重新載入),讓出站生效。",
+        "fromSubsTitle": "來自出站訂閱(唯讀)",
+        "fromSubsDesc": "從您啟用中的訂閱匯入。請於上方的「訂閱」面板中管理。",
+        "toastLoadFailed": "載入訂閱失敗",
+        "toastUrlRequired": "訂閱 URL 為必填",
+        "toastAdded": "訂閱已新增",
+        "toastAddFailed": "新增訂閱失敗",
+        "toastRefreshed": "已重新整理",
+        "toastRefreshFailed": "重新整理失敗",
+        "toastDeleted": "已刪除",
+        "toastDeleteFailed": "刪除失敗"
+      },
       "balancer": {
       "balancer": {
         "addBalancer": "新增負載均衡",
         "addBalancer": "新增負載均衡",
         "editBalancer": "編輯負載均衡",
         "editBalancer": "編輯負載均衡",

+ 3 - 0
web/web.go

@@ -294,6 +294,9 @@ func (s *Server) startTask(restartXray bool) {
 
 
 	s.cron.AddJob("@every 5s", job.NewNodeTrafficSyncJob())
 	s.cron.AddJob("@every 5s", job.NewNodeTrafficSyncJob())
 
 
+	// Outbound subscription auto-refresh (respects per-sub updateInterval)
+	s.cron.AddJob("@every 5m", job.NewOutboundSubscriptionJob())
+
 	// check client ips from log file every day
 	// check client ips from log file every day
 	s.cron.AddJob("@daily", job.NewClearLogsJob())
 	s.cron.AddJob("@daily", job.NewClearLogsJob())