diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 54a6d109e..5e3965fee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -162,7 +162,7 @@ Locale strings live in `web/translation/.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. | | 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. diff --git a/database/db.go b/database/db.go index b3b914e7f..ebcd1c2ac 100644 --- a/database/db.go +++ b/database/db.go @@ -73,6 +73,7 @@ func initModels() error { &model.ClientGroup{}, &model.InboundFallback{}, &model.NodeClientTraffic{}, + &model.OutboundSubscription{}, } for _, mdl := range models { if err := db.AutoMigrate(mdl); err != nil { diff --git a/database/migrate_data.go b/database/migrate_data.go index 89c8387c9..f59b8c489 100644 --- a/database/migrate_data.go +++ b/database/migrate_data.go @@ -20,9 +20,20 @@ import ( "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 { return []any{ &model.User{}, @@ -39,6 +50,7 @@ func migrationModels() []any { &model.ClientInbound{}, &model.InboundFallback{}, &model.NodeClientTraffic{}, + &model.OutboundSubscription{}, } } diff --git a/database/model/model.go b/database/model/model.go index 4077995e8..9da063f7b 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -705,6 +705,28 @@ type ClientMergeConflict struct { 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 { var conflicts []ClientMergeConflict keep := func(field string, oldV, newV, kept any) { diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 3ec267881..56385135b 100644 --- a/frontend/public/openapi.json +++ b/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}": { "get": { "tags": [ diff --git a/frontend/src/hooks/useXraySetting.ts b/frontend/src/hooks/useXraySetting.ts index bd1992af0..bef6294c0 100644 --- a/frontend/src/hooks/useXraySetting.ts +++ b/frontend/src/hooks/useXraySetting.ts @@ -51,9 +51,12 @@ export interface UseXraySettingResult { setOutboundTestUrl: (v: string) => void; inboundTags: string[]; clientReverseTags: string[]; + subscriptionOutbounds: unknown[]; + subscriptionOutboundTags: string[]; restartResult: string; outboundsTraffic: OutboundTrafficRow[]; outboundTestStates: Record; + subscriptionTestStates: Record; testingAll: boolean; fetchAll: () => Promise; fetchOutboundsTraffic: () => Promise; @@ -63,6 +66,11 @@ export interface UseXraySettingResult { outbound: unknown, mode?: string, ) => Promise; + testSubscriptionOutbound: ( + tag: string, + outbound: unknown, + mode?: string, + ) => Promise; testAllOutbounds: (mode?: string) => Promise; saveAll: () => Promise; resetToDefault: () => Promise; @@ -118,8 +126,13 @@ export function useXraySetting(): UseXraySettingResult { const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL); const [inboundTags, setInboundTags] = useState([]); const [clientReverseTags, setClientReverseTags] = useState([]); + const [subscriptionOutbounds, setSubscriptionOutbounds] = useState([]); + const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState([]); const [restartResult, setRestartResult] = useState(''); const [outboundTestStates, setOutboundTestStates] = useState>({}); + // Subscription outbounds aren't in templateSettings.outbounds, so their test + // results are keyed by tag rather than by index. + const [subscriptionTestStates, setSubscriptionTestStates] = useState>({}); const [testingAll, setTestingAll] = useState(false); const oldXraySettingRef = useRef(''); @@ -146,6 +159,8 @@ export function useXraySetting(): UseXraySettingResult { syncingRef.current = false; setInboundTags(obj.inboundTags || []); setClientReverseTags(obj.clientReverseTags || []); + setSubscriptionOutbounds(obj.subscriptionOutbounds || []); + setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []); const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL; setOutboundTestUrlState(nextUrl); oldOutboundTestUrlRef.current = nextUrl; @@ -255,6 +270,26 @@ export function useXraySetting(): UseXraySettingResult { const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending; + // 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 => { + try { + const raw = await HttpUtil.post('/panel/api/xray/testOutbound', { + outbound: JSON.stringify(outbound), + allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), + mode: effMode, + }); + const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound'); + if (msg?.success && msg.obj) return msg.obj; + return { success: false, error: msg?.msg || 'Unknown error', mode: effMode }; + } catch (e) { + return { success: false, error: String(e), mode: effMode }; + } + }, + [], + ); + const testOutbound = useCallback( async (index: number, outbound: unknown, mode = 'tcp'): Promise => { if (!outbound) return null; @@ -263,39 +298,28 @@ export function useXraySetting(): UseXraySettingResult { ...prev, [index]: { testing: true, result: null, mode: effMode }, })); - try { - const raw = await HttpUtil.post('/panel/api/xray/testOutbound', { - outbound: JSON.stringify(outbound), - allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []), - mode: effMode, - }); - 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 }, - }, - })); - } catch (e) { - setOutboundTestStates((prev) => ({ - ...prev, - [index]: { - testing: false, - result: { success: false, error: String(e), mode: effMode }, - }, - })); - } - return null; + 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 => { + 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') => { @@ -358,14 +382,18 @@ export function useXraySetting(): UseXraySettingResult { setOutboundTestUrl, inboundTags, clientReverseTags, + subscriptionOutbounds, + subscriptionOutboundTags, restartResult, outboundsTraffic, outboundTestStates, + subscriptionTestStates, testingAll, fetchAll, fetchOutboundsTraffic: fetchOutboundsTrafficCb, resetOutboundsTraffic, testOutbound, + testSubscriptionOutbound, testAllOutbounds, saveAll, resetToDefault, @@ -384,14 +412,18 @@ export function useXraySetting(): UseXraySettingResult { setOutboundTestUrl, inboundTags, clientReverseTags, + subscriptionOutbounds, + subscriptionOutboundTags, restartResult, outboundsTraffic, outboundTestStates, + subscriptionTestStates, testingAll, fetchAll, fetchOutboundsTrafficCb, resetOutboundsTraffic, testOutbound, + testSubscriptionOutbound, testAllOutbounds, saveAll, resetToDefault, diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index 4ad7cf60b..35c7864f8 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/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 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 = { dashboard: DashboardOutlined, @@ -52,6 +52,7 @@ const iconByName: Record = { cluster: ClusterOutlined, logout: LogoutOutlined, apidocs: ApiOutlined, + outbound: UploadOutlined, }; function readCollapsed(): boolean { @@ -137,6 +138,7 @@ export default function AppSidebar() { { key: '/clients', icon: 'team', title: t('menu.clients') }, { key: '/groups', icon: 'groups', title: t('menu.groups') }, { 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: '/xray', icon: 'tool', title: t('menu.xray') }, { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') }, @@ -162,7 +164,6 @@ export default function AppSidebar() { const xrayChildren = useMemo>(() => [ { key: '/xray#basic', icon: , label: t('pages.xray.basicTemplate') }, { key: '/xray#routing', icon: , label: t('pages.xray.Routings') }, - { key: '/xray#outbound', icon: , label: t('pages.xray.Outbounds') }, { key: '/xray#balancer', icon: , label: t('pages.xray.Balancers') }, { key: '/xray#dns', icon: , label: 'DNS' }, { key: '/xray#advanced', icon: , label: t('pages.xray.advancedTemplate') }, @@ -176,7 +177,9 @@ export default function AppSidebar() { ? `/xray${hash || '#basic'}` : (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(() => (openSubmenu ? [openSubmenu] : [])); useEffect(() => { if (openSubmenu) { diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 84da5cc46..e2a71cc5c 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1085,6 +1085,74 @@ export const sections: readonly Section[] = [ ], 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-".' }, + { 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).' }, + ], + }, ], }, diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx index 53b2909e8..b11dc4ff3 100644 --- a/frontend/src/pages/xray/XrayPage.tsx +++ b/frontend/src/pages/xray/XrayPage.tsx @@ -60,13 +60,17 @@ export default function XrayPage() { setOutboundTestUrl, inboundTags, clientReverseTags, + subscriptionOutbounds, + subscriptionOutboundTags, restartResult, outboundsTraffic, outboundTestStates, + subscriptionTestStates, testingAll, fetchAll, resetOutboundsTraffic, testOutbound, + testSubscriptionOutbound, testAllOutbounds, saveAll, resetToDefault, @@ -99,6 +103,11 @@ export default function XrayPage() { if (outbound) await testOutbound(idx, outbound, mode); } + async function onTestSubscription(outbound: Record, mode: string) { + const tag = typeof outbound?.tag === 'string' ? outbound.tag : ''; + if (tag) await testSubscriptionOutbound(tag, outbound, mode); + } + function onAddOutbound(outbound: Record) { mutate((tt) => { if (!Array.isArray(tt.outbounds)) tt.outbounds = []; @@ -214,6 +223,7 @@ export default function XrayPage() { setTemplateSettings={setTemplateSettings} inboundTags={inboundTags} clientReverseTags={clientReverseTags} + subscriptionOutboundTags={subscriptionOutboundTags} isMobile={isMobile} /> ); @@ -224,14 +234,18 @@ export default function XrayPage() { setTemplateSettings={setTemplateSettings} outboundsTraffic={outboundsTraffic} outboundTestStates={outboundTestStates} + subscriptionTestStates={subscriptionTestStates} testingAll={testingAll} inboundTags={inboundTags} + subscriptionOutbounds={subscriptionOutbounds} isMobile={isMobile} onResetTraffic={resetOutboundsTraffic} onTest={onTestOutbound} + onTestSubscription={onTestSubscription} onTestAll={testAllOutbounds} onShowWarp={() => setWarpOpen(true)} onShowNord={() => setNordOpen(true)} + onRefreshXrayData={fetchAll} /> ); case 'balancer': @@ -240,6 +254,7 @@ export default function XrayPage() { templateSettings={templateSettings} setTemplateSettings={setTemplateSettings} clientReverseTags={clientReverseTags} + subscriptionOutboundTags={subscriptionOutboundTags} isMobile={isMobile} /> ); diff --git a/frontend/src/pages/xray/balancers/BalancersTab.tsx b/frontend/src/pages/xray/balancers/BalancersTab.tsx index 8a6a71ce2..ad624cb05 100644 --- a/frontend/src/pages/xray/balancers/BalancersTab.tsx +++ b/frontend/src/pages/xray/balancers/BalancersTab.tsx @@ -18,6 +18,7 @@ interface BalancersTabProps { templateSettings: XraySettingsValue | null; setTemplateSettings: SetTemplate; clientReverseTags: string[]; + subscriptionOutboundTags?: string[]; isMobile: boolean; } @@ -90,6 +91,7 @@ export default function BalancersTab({ templateSettings, setTemplateSettings, clientReverseTags, + subscriptionOutboundTags, isMobile, }: BalancersTabProps) { const { t } = useTranslation(); @@ -118,8 +120,11 @@ export default function BalancersTab({ for (const tag of clientReverseTags || []) { if (tag) tags.add(tag); } + for (const tag of subscriptionOutboundTags || []) { + if (tag) tags.add(tag); + } return [...tags]; - }, [templateSettings?.outbounds, clientReverseTags]); + }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]); const otherTags = useMemo(() => { if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean); diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.css b/frontend/src/pages/xray/outbounds/OutboundsTab.css index 20cf26337..6bc9a0084 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.css +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.css @@ -209,3 +209,17 @@ .outbound-test-popover .dot-fail { 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; +} diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index a68a65e95..be8f4c9c6 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -3,22 +3,40 @@ import { useTranslation } from 'react-i18next'; import { Button, Col, + Dropdown, + Form, + Input, + InputNumber, Modal, Popconfirm, Radio, Row, Space, + Switch, Table, + Tag, Tooltip, + message, } from 'antd'; import { PlusOutlined, CloudOutlined, ApiOutlined, + MoreOutlined, RetweetOutlined, PlayCircleOutlined, + ReloadOutlined, + DeleteOutlined, + EditOutlined, + EyeOutlined, + ArrowUpOutlined, + ArrowDownOutlined, + CheckCircleOutlined, + WarningOutlined, } from '@ant-design/icons'; +import { HttpUtil } from '@/utils'; + import OutboundFormModal from './OutboundFormModal'; import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import './OutboundsTab.css'; @@ -26,20 +44,40 @@ import './OutboundsTab.css'; import type { OutboundRow } from './outbounds-tab-types'; import { useOutboundColumns } from './useOutboundColumns'; 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 { templateSettings: XraySettingsValue | null; setTemplateSettings: SetTemplate; outboundsTraffic: OutboundTrafficRow[]; outboundTestStates: Record; + subscriptionTestStates: Record; testingAll: boolean; inboundTags: string[]; + subscriptionOutbounds?: unknown[]; isMobile: boolean; onResetTraffic: (tag: string) => void; onTest: (index: number, mode: string) => void; + onTestSubscription: (outbound: Record, mode: string) => void; onTestAll: (mode: string) => void; onShowWarp: () => void; onShowNord: () => void; + onRefreshXrayData?: () => void; } export default function OutboundsTab({ @@ -47,23 +85,49 @@ export default function OutboundsTab({ setTemplateSettings, outboundsTraffic, outboundTestStates, + subscriptionTestStates, testingAll, inboundTags: _inboundTags, + subscriptionOutbounds, isMobile, onResetTraffic, onTest, + onTestSubscription, onTestAll, onShowWarp, onShowNord, + onRefreshXrayData, }: OutboundsTabProps) { const { t } = useTranslation(); const [modal, modalContextHolder] = Modal.useModal(); + const [messageApi, messageContextHolder] = message.useMessage(); const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp'); const [modalOpen, setModalOpen] = useState(false); const [editingOutbound, setEditingOutbound] = useState | null>(null); const [editingIndex, setEditingIndex] = useState(null); const [existingTags, setExistingTags] = useState([]); + // Subscription manager (CRUD + reorder + refresh + preview) + const [subDrawerOpen, setSubDrawerOpen] = useState(false); + const [subs, setSubs] = useState([]); + const [subsLoading, setSubsLoading] = useState(false); + const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false }); + const [editingSubId, setEditingSubId] = useState(null); + const [savingSub, setSavingSub] = useState(false); + const [refreshingId, setRefreshingId] = useState(null); + const [refreshingAll, setRefreshingAll] = useState(false); + const [busyId, setBusyId] = useState(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( () => (templateSettings?.outbounds || []) as unknown as OutboundRow[], [templateSettings?.outbounds], @@ -89,6 +153,11 @@ export default function OutboundsTab({ setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg)); setModalOpen(true); } + + function openSubManager() { + setSubDrawerOpen(true); + loadSubs(); + } function openEdit(idx: number) { setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record); 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(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({ testMode, rows, @@ -164,6 +396,7 @@ export default function OutboundsTab({ return ( <> {modalContextHolder} + {messageContextHolder} @@ -171,12 +404,20 @@ export default function OutboundsTab({ - - + , label: 'WARP', onClick: onShowWarp }, + { key: 'nord', icon: , label: 'NordVPN', onClick: onShowNord }, + ], + }} + > + + @@ -232,7 +473,182 @@ export default function OutboundsTab({ onClose={() => setModalOpen(false)} onConfirm={onConfirm} /> + + {/* Subscription outbounds (read-only, merged at runtime) */} + {Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && ( + + )} + + setSubDrawerOpen(false)} + footer={null} + width={isMobile ? '100%' : 640} + destroyOnHidden + > + +
+ {editingSubId != null && ( +
+ {t('edit')} + {newSub.remark || newSub.url} +
+ )} +
+ + setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} /> + + + setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} /> + + + setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} /> + + + + setIntervalHM(Number(v) || 0, intervalMinutes)} + style={{ width: 80 }} + /> {t('pages.xray.outboundSub.hours')} + setIntervalHM(intervalHours, Number(v) || 0)} + style={{ width: 80 }} + /> {t('pages.xray.outboundSub.minutes')} + +
+ {t('pages.xray.outboundSub.intervalHint')} +
+
+ + setNewSub({ ...newSub, enabled: v })} /> + + + setNewSub({ ...newSub, allowPrivate: v })} /> +
+ {t('pages.xray.outboundSub.allowPrivateHint')} +
+
+ + setNewSub({ ...newSub, prepend: v })} /> +
+ {t('pages.xray.outboundSub.prependHint')} +
+
+ + + + {editingSubId != null && } + + {previewData && previewData.length > 0 && ( +
+
{previewData.length} · {t('pages.xray.Outbounds')}
+
+ {previewData.map((o, i) => ( + {o?.tag || '—'}{o?.protocol ? ` · ${o.protocol}` : ''} + ))} +
+
+ )} +
+
+ +
+
+ {t('pages.xray.outboundSub.active')} + + )} +
+ {subs.length === 0 ? ( +
{t('pages.xray.outboundSub.empty')}
+ ) : ( + r.id} + pagination={false} + scroll={{ x: true }} + columns={[ + { + title: '', + key: 'order', + width: 56, + render: (_: unknown, r: OutboundSub, index: number) => ( + +
r.key} pagination={false} size="small" /> + + ); +} diff --git a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts b/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts index 4e76fb1bc..91f7ff6cb 100644 --- a/frontend/src/pages/xray/outbounds/outbounds-tab-helpers.ts +++ b/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 }; } -export function isTesting(states: Record, idx: number): boolean { +export function isTesting(states: Record, idx: K): boolean { return !!states?.[idx]?.testing; } -export function testResult(states: Record, idx: number) { +export function testResult(states: Record, idx: K) { return states?.[idx]?.result || null; } diff --git a/frontend/src/pages/xray/routing/RoutingTab.tsx b/frontend/src/pages/xray/routing/RoutingTab.tsx index 15aad6612..21db1cbbe 100644 --- a/frontend/src/pages/xray/routing/RoutingTab.tsx +++ b/frontend/src/pages/xray/routing/RoutingTab.tsx @@ -20,6 +20,7 @@ interface RoutingTabProps { setTemplateSettings: SetTemplate; inboundTags: string[]; clientReverseTags: string[]; + subscriptionOutboundTags?: string[]; isMobile: boolean; } @@ -28,6 +29,7 @@ export default function RoutingTab({ setTemplateSettings, inboundTags, clientReverseTags, + subscriptionOutboundTags, isMobile, }: RoutingTabProps) { const { t } = useTranslation(); @@ -116,8 +118,11 @@ export default function RoutingTab({ for (const tag of clientReverseTags || []) { if (tag) out.add(tag); } + for (const tag of subscriptionOutboundTags || []) { + if (tag) out.add(tag); + } return [...out]; - }, [templateSettings?.outbounds, clientReverseTags]); + }, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]); const balancerTagOptions = useMemo(() => { const out: string[] = ['']; diff --git a/frontend/src/schemas/xray.ts b/frontend/src/schemas/xray.ts index 1eeedb4d2..bb2d81da2 100644 --- a/frontend/src/schemas/xray.ts +++ b/frontend/src/schemas/xray.ts @@ -40,6 +40,11 @@ export const XrayConfigPayloadSchema = z.object({ inboundTags: z.array(z.string()).optional(), clientReverseTags: z.array(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(); export const OutboundTrafficRowSchema = z.object({ diff --git a/util/link/outbound.go b/util/link/outbound.go new file mode 100644 index 000000000..77c8ec1eb --- /dev/null +++ b/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 +} diff --git a/util/link/outbound_test.go b/util/link/outbound_test.go new file mode 100644 index 000000000..95b2ca9ca --- /dev/null +++ b/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://uuid@1.2.3.4: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) + } +} \ No newline at end of file diff --git a/web/controller/xray_setting.go b/web/controller/xray_setting.go index 396e0a6af..3ba9dcfaf 100644 --- a/web/controller/xray_setting.go +++ b/web/controller/xray_setting.go @@ -2,6 +2,7 @@ package controller import ( "encoding/json" + "fmt" "github.com/mhsanaei/3x-ui/v3/util/common" "github.com/mhsanaei/3x-ui/v3/web/service" @@ -14,10 +15,11 @@ type XraySettingController struct { XraySettingService service.XraySettingService SettingService service.SettingService 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. @@ -40,6 +42,16 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) { g.POST("/update", a.updateSetting) g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic) 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. @@ -85,6 +97,17 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) { "clientReverseTags": json.RawMessage(clientReverseTags), "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) if err != nil { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err) @@ -227,3 +250,149 @@ func (a *XraySettingController) testOutbound(c *gin.Context) { 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 +} diff --git a/web/job/outbound_subscription_job.go b/web/job/outbound_subscription_job.go new file mode 100644 index 000000000..0510a8a7d --- /dev/null +++ b/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) + } +} \ No newline at end of file diff --git a/web/service/outbound_subscription.go b/web/service/outbound_subscription.go new file mode 100644 index 000000000..d5b206e6d --- /dev/null +++ b/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-" 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-" 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. +*/ \ No newline at end of file diff --git a/web/service/outbound_subscription_test.go b/web/service/outbound_subscription_test.go new file mode 100644 index 000000000..3a7072caf --- /dev/null +++ b/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- 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) + } + }) +} diff --git a/web/service/xray.go b/web/service/xray.go index 36dcabe82..07adaedb1 100644 --- a/web/service/xray.go +++ b/web/service/xray.go @@ -254,9 +254,51 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { inboundConfig := inbound.GenXrayInboundConfig() 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 } +// 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 // absolute paths under config.GetLogFolder(), so Xray writes those files // alongside the panel's other logs regardless of the working directory the diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json index 700b84fe2..f78766dc0 100644 --- a/web/translation/ar-EG.json +++ b/web/translation/ar-EG.json @@ -1356,6 +1356,58 @@ "privateKey": "المفتاح الخاص", "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": { "addBalancer": "أضف موازن تحميل", "editBalancer": "عدل موازن التحميل", diff --git a/web/translation/en-US.json b/web/translation/en-US.json index dda4682e2..75bf5e0d9 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -1199,6 +1199,8 @@ "Inbounds": "Inbounds", "InboundsDesc": "Accepting the specific clients.", "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", "balancerTagRequired": "Tag is required", "balancerSelectorRequired": "Pick at least one outbound", @@ -1357,6 +1359,56 @@ "privateKey": "Private Key", "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": { "addBalancer": "Add Balancer", "editBalancer": "Edit Balancer", diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json index 96f59c720..f9ccbcd7d 100644 --- a/web/translation/es-ES.json +++ b/web/translation/es-ES.json @@ -1356,6 +1356,58 @@ "privateKey": "Clave privada", "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": { "addBalancer": "Agregar equilibrador", "editBalancer": "Editar balanceador", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index 3de4877ea..6af6a2fdf 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -1356,6 +1356,58 @@ "privateKey": "کلید خصوصی", "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": { "addBalancer": "افزودن بالانسر", "editBalancer": "ویرایش بالانسر", diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json index d67cae832..a556d989d 100644 --- a/web/translation/id-ID.json +++ b/web/translation/id-ID.json @@ -1356,6 +1356,58 @@ "privateKey": "Kunci Privat", "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": { "addBalancer": "Tambahkan Penyeimbang", "editBalancer": "Sunting Penyeimbang", diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json index 58d968e87..1cecf52c3 100644 --- a/web/translation/ja-JP.json +++ b/web/translation/ja-JP.json @@ -1356,6 +1356,58 @@ "privateKey": "秘密鍵", "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": { "addBalancer": "負荷分散追加", "editBalancer": "負荷分散編集", diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json index 4327cbd5f..65ffd1c75 100644 --- a/web/translation/pt-BR.json +++ b/web/translation/pt-BR.json @@ -1356,6 +1356,58 @@ "privateKey": "Chave Privada", "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": { "addBalancer": "Adicionar Balanceador", "editBalancer": "Editar Balanceador", diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json index 8bbaeae42..f8f9100a9 100644 --- a/web/translation/ru-RU.json +++ b/web/translation/ru-RU.json @@ -1356,6 +1356,58 @@ "privateKey": "Приватный ключ", "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": { "addBalancer": "Создать балансировщик", "editBalancer": "Редактировать балансировщик", diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json index 4bfc03ac9..3a4c0655c 100644 --- a/web/translation/tr-TR.json +++ b/web/translation/tr-TR.json @@ -1356,6 +1356,58 @@ "privateKey": "Özel Anahtar", "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": { "addBalancer": "Dengeleyici Ekle", "editBalancer": "Dengeleyiciyi Düzenle", diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json index b1ecdd8ca..59f3c48af 100644 --- a/web/translation/uk-UA.json +++ b/web/translation/uk-UA.json @@ -1356,6 +1356,58 @@ "privateKey": "Приватний ключ", "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": { "addBalancer": "Додати балансир", "editBalancer": "Редагувати балансир", diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json index 5176875dd..7da171254 100644 --- a/web/translation/vi-VN.json +++ b/web/translation/vi-VN.json @@ -1356,6 +1356,58 @@ "privateKey": "Khóa riêng", "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": { "addBalancer": "Thêm cân bằng", "editBalancer": "Chỉnh sửa cân bằng", diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json index 4e4352481..d401fa285 100644 --- a/web/translation/zh-CN.json +++ b/web/translation/zh-CN.json @@ -1356,6 +1356,58 @@ "privateKey": "私钥", "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": { "addBalancer": "添加负载均衡", "editBalancer": "编辑负载均衡", diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json index 8c02b968c..8a99916ea 100644 --- a/web/translation/zh-TW.json +++ b/web/translation/zh-TW.json @@ -1356,6 +1356,58 @@ "privateKey": "私密金鑰", "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": { "addBalancer": "新增負載均衡", "editBalancer": "編輯負載均衡", diff --git a/web/web.go b/web/web.go index 14f00cb4e..e63dc0e4d 100644 --- a/web/web.go +++ b/web/web.go @@ -294,6 +294,9 @@ func (s *Server) startTask(restartXray bool) { 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 s.cron.AddJob("@daily", job.NewClearLogsJob())