From 522b1b64b07b6b5a802e187ea005936501d3e7ce Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 26 Jun 2026 11:40:13 +0200 Subject: [PATCH 01/13] fix(logger): prevent nil-deref panic in migrate/setting CLI paths The package-level logger is nil until InitLogger runs, which only happens in runWebServer. The migrate and setting subcommands log without initializing it; PR #5520 added a logger.Info on a success path in MigrationRestoreVisionFlow, so 'x-ui migrate' segfaults on installs with a VLESS inbound needing Vision-flow restoration. Initialize logger to a usable default at package load so no code path can nil-deref it, and set up the dual backend in migrateDb so migration steps are logged like runWebServer. Fixes #5581 --- internal/logger/logger.go | 4 +++- main.go | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 215c597cd..00f8b503c 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -29,7 +29,9 @@ const ( ) var ( - logger *logging.Logger + // Initialized to a usable default so logging never nil-derefs before InitLogger + // runs — the "migrate" and "setting" CLI subcommands log without calling it. + logger = logging.MustGetLogger("x-ui") fileRotate *lumberjack.Logger // nil when file backend disabled // logBuffer maintains recent log entries in memory for web UI retrieval; diff --git a/main.go b/main.go index c03063e40..b32ea43f3 100644 --- a/main.go +++ b/main.go @@ -484,6 +484,7 @@ func GetApiToken(getApiToken bool) { func migrateDb() { inboundService := service.InboundService{} + logger.InitLogger(logging.INFO) err := database.InitDB(config.GetDBPath()) if err != nil { log.Fatal(err) From 8e4c368200ca7e3e923162a868a9e79f4c661580 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 26 Jun 2026 18:01:51 +0200 Subject: [PATCH 02/13] feat(update): allow opting into the dev channel from a stable build The panel version button opened the GitHub releases page on a stable, up-to-date build, and the dev-channel toggle only rendered on dev builds, so there was no in-panel path from stable to dev. Drop the IsDevBuild() guard in devChannelActive (the toggle alone drives the channel now), always open the update modal instead of releases, and always render the Dev channel switch. --- frontend/src/pages/index/IndexPage.tsx | 11 ++-------- frontend/src/pages/index/PanelUpdateModal.tsx | 22 ++++++++----------- internal/web/service/panel/panel.go | 9 ++++---- 3 files changed, 15 insertions(+), 27 deletions(-) diff --git a/frontend/src/pages/index/IndexPage.tsx b/frontend/src/pages/index/IndexPage.tsx index 1a22a321a..6e49b5a60 100644 --- a/frontend/src/pages/index/IndexPage.tsx +++ b/frontend/src/pages/index/IndexPage.tsx @@ -66,7 +66,6 @@ export default function IndexPage() { useEffect(() => { setMessageInstance(messageApi); }, [messageApi]); const [accessLogEnable, setAccessLogEnable] = useState(false); - const [isDevBuild, setIsDevBuild] = useState(false); const [devChannelEnable, setDevChannelEnable] = useState(false); const [panelUpdateInfo, setPanelUpdateInfo] = useState({ currentVersion: '', @@ -90,12 +89,11 @@ export default function IndexPage() { const [loadingTip, setLoadingTip] = useState(t('loading')); useEffect(() => { - HttpUtil.post<{ accessLogEnable?: boolean; isDevBuild?: boolean; devChannelEnable?: boolean }>( + HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>( '/panel/api/setting/defaultSettings', ).then((msg) => { if (msg?.success && msg.obj) { setAccessLogEnable(!!msg.obj.accessLogEnable); - setIsDevBuild(!!msg.obj.isDevBuild); setDevChannelEnable(!!msg.obj.devChannelEnable); } }); @@ -128,11 +126,7 @@ export default function IndexPage() { }, [refresh]); function openPanelVersion() { - if (panelUpdateInfo.updateAvailable || isDevBuild) { - setPanelUpdateOpen(true); - } else { - window.open('https://github.com/MHSanaei/3x-ui/releases', '_blank', 'noopener,noreferrer'); - } + setPanelUpdateOpen(true); } async function handleChannelChange(dev: boolean) { @@ -463,7 +457,6 @@ export default function IndexPage() { setPanelUpdateOpen(false)} diff --git a/frontend/src/pages/index/PanelUpdateModal.tsx b/frontend/src/pages/index/PanelUpdateModal.tsx index a6000b0f5..0e8bb788b 100644 --- a/frontend/src/pages/index/PanelUpdateModal.tsx +++ b/frontend/src/pages/index/PanelUpdateModal.tsx @@ -25,7 +25,6 @@ interface BusyEvent { interface PanelUpdateModalProps { open: boolean; info: PanelUpdateInfo; - isDevBuild?: boolean; devChannelEnable?: boolean; onChannelChange?: (dev: boolean) => void | Promise; onClose: () => void; @@ -35,7 +34,6 @@ interface PanelUpdateModalProps { export default function PanelUpdateModal({ open, info, - isDevBuild, devChannelEnable, onChannelChange, onClose, @@ -113,18 +111,16 @@ export default function PanelUpdateModal({ /> )} - {isDevBuild && ( -
-
- {t('pages.index.devChannel')} - -
+
+
+ {t('pages.index.devChannel')} +
- )} +
{devChannelEnable && ( Date: Fri, 26 Jun 2026 18:55:32 +0200 Subject: [PATCH 03/13] feat(sidebar): add documentation link button Add a Docs button next to the donate button in the sidebar and mobile drawer linking to https://docs.sanaei.dev/, with menu.docs translations across all 13 languages. --- frontend/src/layouts/AppSidebar.css | 27 +++++++++++++++++++++++++++ frontend/src/layouts/AppSidebar.tsx | 19 +++++++++++++++++++ internal/web/translation/ar-EG.json | 3 ++- internal/web/translation/en-US.json | 3 ++- internal/web/translation/es-ES.json | 3 ++- internal/web/translation/fa-IR.json | 3 ++- internal/web/translation/id-ID.json | 3 ++- internal/web/translation/ja-JP.json | 3 ++- internal/web/translation/pt-BR.json | 3 ++- internal/web/translation/ru-RU.json | 3 ++- internal/web/translation/tr-TR.json | 3 ++- internal/web/translation/uk-UA.json | 3 ++- internal/web/translation/vi-VN.json | 3 ++- internal/web/translation/zh-CN.json | 3 ++- internal/web/translation/zh-TW.json | 3 ++- 15 files changed, 72 insertions(+), 13 deletions(-) diff --git a/frontend/src/layouts/AppSidebar.css b/frontend/src/layouts/AppSidebar.css index 4784572f8..074407f22 100644 --- a/frontend/src/layouts/AppSidebar.css +++ b/frontend/src/layouts/AppSidebar.css @@ -75,6 +75,33 @@ font-size: 16px; } +.sidebar-docs { + background: transparent; + border: none; + width: 30px; + height: 30px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ant-color-text-secondary); + text-decoration: none; + flex-shrink: 0; + transition: background-color 0.2s, transform 0.15s, color 0.2s; +} + +.sidebar-docs:hover, +.sidebar-docs:focus-visible { + background-color: color-mix(in srgb, var(--ant-color-primary) 12%, transparent); + color: var(--ant-color-primary); + transform: scale(1.08); + outline: none; +} + +.sidebar-docs .anticon { + font-size: 16px; +} + .sidebar-theme-cycle { background: transparent; border: none; diff --git a/frontend/src/layouts/AppSidebar.tsx b/frontend/src/layouts/AppSidebar.tsx index 22e747adf..887e0d4f0 100644 --- a/frontend/src/layouts/AppSidebar.tsx +++ b/frontend/src/layouts/AppSidebar.tsx @@ -23,6 +23,7 @@ import { MessageOutlined, MoonFilled, MoonOutlined, + ReadOutlined, SafetyOutlined, SettingOutlined, SunOutlined, @@ -40,6 +41,7 @@ import './AppSidebar.css'; const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed'; const DONATE_URL = 'https://donate.sanaei.dev/'; +const DOCS_URL = 'https://docs.sanaei.dev/'; const REPO_URL = 'https://github.com/MHSanaei/3x-ui'; const LOGOUT_KEY = '__logout__'; @@ -83,6 +85,21 @@ function DonateButton({ ariaLabel }: { ariaLabel: string }) { ); } +function DocsButton({ ariaLabel }: { ariaLabel: string }) { + return ( + + + + ); +} + function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) { if (!version) return null; const label = formatPanelVersion(version); @@ -254,6 +271,7 @@ export default function AppSidebar() {
{!collapsed && (
+ 3X-UI
+ Date: Fri, 26 Jun 2026 22:18:47 +0200 Subject: [PATCH 04/13] feat(reality): add live REALITY target scanner with IP/CIDR discovery Replace the static reality-targets list with a server-side TLS 1.3 probe that checks TLS 1.3 + HTTP/2 + X25519 + a trusted certificate. - Single-domain validate auto-fills target and serverNames from the cert SAN - Discovery scans an IP/CIDR without SNI to find new targets from their certificates, deduped and ranked by feasibility then latency, private-IP guarded via netsafe - New endpoints scanRealityTarget and scanRealityTargets with RealityScanResult, plus openapigen and api-docs entries - Add scanner strings to all 13 locales - Replace deprecated AntD Alert message prop with title across the panel --- frontend/README.md | 2 +- frontend/public/openapi.json | 237 +++++++++++ frontend/src/generated/examples.ts | 22 + frontend/src/generated/schemas.ts | 98 +++++ frontend/src/generated/types.ts | 21 + frontend/src/generated/zod.ts | 22 + frontend/src/models/reality-targets.ts | 23 -- frontend/src/pages/api-docs/endpoints.ts | 21 + .../pages/clients/BulkAttachInboundsModal.tsx | 2 +- .../pages/clients/BulkDetachInboundsModal.tsx | 2 +- .../src/pages/groups/GroupAddClientsModal.tsx | 2 +- .../inbounds/clients/AttachClientsModal.tsx | 2 +- .../clients/AttachExistingClientsModal.tsx | 2 +- .../pages/inbounds/form/InboundFormModal.tsx | 16 +- .../security/RealityTargetScannerModal.tsx | 174 ++++++++ .../pages/inbounds/form/security/reality.tsx | 79 +++- .../pages/inbounds/form/useSecurityActions.ts | 70 +++- frontend/src/pages/nodes/NodesPage.tsx | 2 +- .../src/test/inbound-form-blocks.test.tsx | 6 +- internal/web/controller/server.go | 25 ++ internal/web/service/reality_scan.go | 391 ++++++++++++++++++ internal/web/service/reality_scan_test.go | 111 +++++ internal/web/translation/ar-EG.json | 17 + internal/web/translation/en-US.json | 17 + internal/web/translation/es-ES.json | 17 + internal/web/translation/fa-IR.json | 17 + internal/web/translation/id-ID.json | 17 + internal/web/translation/ja-JP.json | 17 + internal/web/translation/pt-BR.json | 17 + internal/web/translation/ru-RU.json | 17 + internal/web/translation/tr-TR.json | 17 + internal/web/translation/uk-UA.json | 17 + internal/web/translation/vi-VN.json | 17 + internal/web/translation/zh-CN.json | 17 + internal/web/translation/zh-TW.json | 17 + tools/openapigen/main.go | 1 + 36 files changed, 1489 insertions(+), 63 deletions(-) delete mode 100644 frontend/src/models/reality-targets.ts create mode 100644 frontend/src/pages/inbounds/form/security/RealityTargetScannerModal.tsx create mode 100644 internal/web/service/reality_scan.go create mode 100644 internal/web/service/reality_scan_test.go diff --git a/frontend/README.md b/frontend/README.md index b10230666..8a5c5b343 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -100,7 +100,7 @@ frontend/ ├── generated/ # Code-generated zod + ts types from Go │ # (DO NOT hand-edit — regenerated by gen:zod) ├── models/ # Thin legacy types still in transit - │ # (DBInbound, Status, AllSetting, reality-targets) + │ # (DBInbound, Status, AllSetting) ├── styles/ # Shared CSS modules ├── test/ # Vitest specs + golden fixtures │ ├── *.test.ts diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 2310f602c..33385c8ad 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2146,6 +2146,104 @@ ], "type": "object" }, + "RealityScanResult": { + "properties": { + "alpn": { + "example": "h2", + "type": "string" + }, + "certIssuer": { + "example": "Google Trust Services", + "type": "string" + }, + "certSubject": { + "example": "cloudflare.com", + "type": "string" + }, + "certValid": { + "example": true, + "type": "boolean" + }, + "curveID": { + "example": "X25519", + "type": "string" + }, + "feasible": { + "example": true, + "type": "boolean" + }, + "h2": { + "example": true, + "type": "boolean" + }, + "host": { + "example": "www.cloudflare.com", + "type": "string" + }, + "ip": { + "example": "104.16.124.96", + "type": "string" + }, + "latencyMs": { + "example": 180, + "type": "integer" + }, + "notAfter": { + "example": "2026-08-01T00:00:00Z", + "type": "string" + }, + "port": { + "example": 443, + "type": "integer" + }, + "reason": { + "type": "string" + }, + "serverNames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "target": { + "example": "www.cloudflare.com:443", + "type": "string" + }, + "tls13": { + "example": true, + "type": "boolean" + }, + "tlsVersion": { + "example": "1.3", + "type": "string" + }, + "x25519": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "alpn", + "certIssuer", + "certSubject", + "certValid", + "curveID", + "feasible", + "h2", + "host", + "ip", + "latencyMs", + "notAfter", + "port", + "reason", + "serverNames", + "target", + "tls13", + "tlsVersion", + "x25519" + ], + "type": "object" + }, "Setting": { "description": "Setting stores key-value configuration settings for the 3x-ui panel.", "properties": { @@ -4637,6 +4735,145 @@ } } }, + "/panel/api/server/scanRealityTarget": { + "post": { + "tags": [ + "Server" + ], + "summary": "Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.", + "operationId": "post_panel_api_server_scanRealityTarget", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "$ref": "#/components/schemas/RealityScanResult" + } + } + }, + "example": { + "success": true, + "obj": { + "alpn": "h2", + "certIssuer": "Google Trust Services", + "certSubject": "cloudflare.com", + "certValid": true, + "curveID": "X25519", + "feasible": true, + "h2": true, + "host": "www.cloudflare.com", + "ip": "104.16.124.96", + "latencyMs": 180, + "notAfter": "2026-08-01T00:00:00Z", + "port": 443, + "reason": "", + "serverNames": [ + "" + ], + "target": "www.cloudflare.com:443", + "tls13": true, + "tlsVersion": "1.3", + "x25519": true + } + } + } + } + } + } + } + }, + "/panel/api/server/scanRealityTargets": { + "post": { + "tags": [ + "Server" + ], + "summary": "Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.", + "operationId": "post_panel_api_server_scanRealityTargets", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RealityScanResult" + } + } + } + }, + "example": { + "success": true, + "obj": [ + { + "alpn": "h2", + "certIssuer": "Google Trust Services", + "certSubject": "cloudflare.com", + "certValid": true, + "curveID": "X25519", + "feasible": true, + "h2": true, + "host": "www.cloudflare.com", + "ip": "104.16.124.96", + "latencyMs": 180, + "notAfter": "2026-08-01T00:00:00Z", + "port": 443, + "reason": "", + "serverNames": [ + "" + ], + "target": "www.cloudflare.com:443", + "tls13": true, + "tlsVersion": "1.3", + "x25519": true + } + ] + } + } + } + } + } + } + }, "/panel/api/server/clientIps": { "get": { "tags": [ diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index b8c1f3d3c..2329d5983 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -463,6 +463,28 @@ export const EXAMPLES: Record = { "xrayState": "", "xrayVersion": "25.10.31" }, + "RealityScanResult": { + "alpn": "h2", + "certIssuer": "Google Trust Services", + "certSubject": "cloudflare.com", + "certValid": true, + "curveID": "X25519", + "feasible": true, + "h2": true, + "host": "www.cloudflare.com", + "ip": "104.16.124.96", + "latencyMs": 180, + "notAfter": "2026-08-01T00:00:00Z", + "port": 443, + "reason": "", + "serverNames": [ + "" + ], + "target": "www.cloudflare.com:443", + "tls13": true, + "tlsVersion": "1.3", + "x25519": true + }, "Setting": { "id": 0, "key": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 4ed467ad8..c0d89936f 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2120,6 +2120,104 @@ export const SCHEMAS: Record = { ], "type": "object" }, + "RealityScanResult": { + "properties": { + "alpn": { + "example": "h2", + "type": "string" + }, + "certIssuer": { + "example": "Google Trust Services", + "type": "string" + }, + "certSubject": { + "example": "cloudflare.com", + "type": "string" + }, + "certValid": { + "example": true, + "type": "boolean" + }, + "curveID": { + "example": "X25519", + "type": "string" + }, + "feasible": { + "example": true, + "type": "boolean" + }, + "h2": { + "example": true, + "type": "boolean" + }, + "host": { + "example": "www.cloudflare.com", + "type": "string" + }, + "ip": { + "example": "104.16.124.96", + "type": "string" + }, + "latencyMs": { + "example": 180, + "type": "integer" + }, + "notAfter": { + "example": "2026-08-01T00:00:00Z", + "type": "string" + }, + "port": { + "example": 443, + "type": "integer" + }, + "reason": { + "type": "string" + }, + "serverNames": { + "items": { + "type": "string" + }, + "type": "array" + }, + "target": { + "example": "www.cloudflare.com:443", + "type": "string" + }, + "tls13": { + "example": true, + "type": "boolean" + }, + "tlsVersion": { + "example": "1.3", + "type": "string" + }, + "x25519": { + "example": true, + "type": "boolean" + } + }, + "required": [ + "alpn", + "certIssuer", + "certSubject", + "certValid", + "curveID", + "feasible", + "h2", + "host", + "ip", + "latencyMs", + "notAfter", + "port", + "reason", + "serverNames", + "target", + "tls13", + "tlsVersion", + "x25519" + ], + "type": "object" + }, "Setting": { "description": "Setting stores key-value configuration settings for the 3x-ui panel.", "properties": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 729961e17..f68d17fb9 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -462,6 +462,27 @@ export interface ProbeResultUI { xrayVersion: string; } +export interface RealityScanResult { + alpn: string; + certIssuer: string; + certSubject: string; + certValid: boolean; + curveID: string; + feasible: boolean; + h2: boolean; + host: string; + ip: string; + latencyMs: number; + notAfter: string; + port: number; + reason: string; + serverNames: string[]; + target: string; + tls13: boolean; + tlsVersion: string; + x25519: boolean; +} + export interface Setting { id: number; key: string; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 92d45296a..bc19547d9 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -494,6 +494,28 @@ export const ProbeResultUISchema = z.object({ }); export type ProbeResultUI = z.infer; +export const RealityScanResultSchema = z.object({ + alpn: z.string(), + certIssuer: z.string(), + certSubject: z.string(), + certValid: z.boolean(), + curveID: z.string(), + feasible: z.boolean(), + h2: z.boolean(), + host: z.string(), + ip: z.string(), + latencyMs: z.number().int(), + notAfter: z.string(), + port: z.number().int(), + reason: z.string(), + serverNames: z.array(z.string()), + target: z.string(), + tls13: z.boolean(), + tlsVersion: z.string(), + x25519: z.boolean(), +}); +export type RealityScanResult = z.infer; + export const SettingSchema = z.object({ id: z.number().int(), key: z.string(), diff --git a/frontend/src/models/reality-targets.ts b/frontend/src/models/reality-targets.ts deleted file mode 100644 index 518c836e0..000000000 --- a/frontend/src/models/reality-targets.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface RealityTarget { - target: string; - sni: string; -} - -export const REALITY_TARGETS: readonly RealityTarget[] = [ - { target: 'www.amazon.com:443', sni: 'www.amazon.com' }, - { target: 'aws.amazon.com:443', sni: 'aws.amazon.com' }, - { target: 'www.oracle.com:443', sni: 'www.oracle.com' }, - { target: 'www.nvidia.com:443', sni: 'www.nvidia.com' }, - { target: 'www.amd.com:443', sni: 'www.amd.com' }, - { target: 'www.intel.com:443', sni: 'www.intel.com' }, - { target: 'www.sony.com:443', sni: 'www.sony.com' }, -]; - -export function getRandomRealityTarget(): RealityTarget { - const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length); - const selected = REALITY_TARGETS[randomIndex]; - return { - target: selected.target, - sni: selected.sni, - }; -} diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 479d2e347..28f377de6 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -489,6 +489,27 @@ export const sections: readonly Section[] = [ body: 'server=cloudflare-dns.com', response: '{\n "success": true,\n "obj": [\n "e8e2d3..."\n ]\n}', }, + { + method: 'POST', + path: '/panel/api/server/scanRealityTarget', + summary: 'Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.', + params: [ + { name: 'target', in: 'body (form)', type: 'string', desc: 'Candidate target as host or host:port (default port 443), e.g. www.cloudflare.com:443.' }, + ], + body: 'target=www.cloudflare.com:443', + responseSchema: 'RealityScanResult', + }, + { + method: 'POST', + path: '/panel/api/server/scanRealityTargets', + summary: 'Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.', + params: [ + { name: 'targets', in: 'body (form)', type: 'string', optional: true, desc: 'Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, a built-in seed list is probed.' }, + ], + body: 'targets=104.16.0.0/24,www.apple.com:443', + responseSchema: 'RealityScanResult', + responseSchemaArray: true, + }, { method: 'GET', path: '/panel/api/server/clientIps', diff --git a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx index 1fd397a3e..f3d4ecf3f 100644 --- a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx +++ b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx @@ -81,7 +81,7 @@ export default function BulkAttachInboundsModal({ {t('pages.clients.attachToInboundsDesc', { count })} {targetOptions.length === 0 ? ( - + ) : ( <> {targetOptions.length === 0 ? ( - + ) : ( <> {rows.length === 0 ? ( - + ) : ( size="small" diff --git a/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx b/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx index a878f2de4..1bdeb21ed 100644 --- a/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx +++ b/frontend/src/pages/inbounds/clients/AttachClientsModal.tsx @@ -192,7 +192,7 @@ export default function AttachClientsModal({ {targetOptions.length === 0 ? ( - + ) : ( + - + - - - - + setScannerOpen(false)} + scanRealityCandidates={scanRealityCandidates} + onPick={applyRealityScanResult} + /> ); } diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts index 980fe6bc8..fe0e8cdf2 100644 --- a/frontend/src/pages/inbounds/form/useSecurityActions.ts +++ b/frontend/src/pages/inbounds/form/useSecurityActions.ts @@ -4,10 +4,10 @@ import type { FormInstance } from 'antd'; import type { MessageInstance } from 'antd/es/message/interface'; import { HttpUtil, RandomUtil } from '@/utils'; -import { getRandomRealityTarget } from '@/models/reality-targets'; import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults'; import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; import type { InboundFormValues } from '@/schemas/forms/inbound-form'; +import type { RealityScanResult } from '@/generated/types'; interface UseSecurityActionsArgs { form: FormInstance; @@ -17,13 +17,15 @@ interface UseSecurityActionsArgs { // Panel" must read the node's own cert paths for a node-assigned inbound — // the central panel's paths don't exist on the node. See issue #4854. nodeId: number | null; + setScanResult: Dispatch>; + setScanning: Dispatch>; } // Server-side TLS / Reality key + certificate generation handlers for the // inbound modal's security tab. Each talks to a /panel server endpoint and // writes the result back into the form. Lifted out of InboundFormModal so // the modal body stays focused on orchestration. -export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseSecurityActionsArgs) { +export function useSecurityActions({ form, setSaving, messageApi, nodeId, setScanResult, setScanning }: UseSecurityActionsArgs) { const { t } = useTranslation(); const genRealityKeypair = async () => { @@ -64,13 +66,55 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS form.setFieldValue(['streamSettings', 'realitySettings', 'settings', 'mldsa65Verify'], ''); }; - const randomizeRealityTarget = () => { - const tgt = getRandomRealityTarget() as { target: string; sni: string }; - form.setFieldValue(['streamSettings', 'realitySettings', 'target'], tgt.target); - form.setFieldValue( - ['streamSettings', 'realitySettings', 'serverNames'], - tgt.sni.split(',').map((s) => s.trim()).filter(Boolean), + const applyRealityScanResult = (r: RealityScanResult) => { + setScanResult(r); + form.setFieldValue(['streamSettings', 'realitySettings', 'target'], r.target); + if (r.serverNames?.length) { + form.setFieldValue(['streamSettings', 'realitySettings', 'serverNames'], r.serverNames); + } + }; + + const scanRealityTarget = async () => { + const target = ((form.getFieldValue(['streamSettings', 'realitySettings', 'target']) as string | undefined) ?? '').trim(); + if (!target) { + messageApi.warning(t('pages.inbounds.form.realityTargetRequired')); + return; + } + setScanning(true); + try { + const msg = await HttpUtil.post( + '/panel/api/server/scanRealityTarget', + { target }, + { silent: true }, + ); + if (!msg?.success || !msg.obj) { + setScanResult(null); + messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError')); + return; + } + const r = msg.obj; + applyRealityScanResult(r); + if (r.feasible) { + messageApi.success(t('pages.inbounds.toasts.scanRealityTargetFeasible')); + } else { + messageApi.warning(r.reason || t('pages.inbounds.toasts.scanRealityTargetNotFeasible')); + } + } finally { + setScanning(false); + } + }; + + const scanRealityCandidates = async (targets?: string): Promise => { + const msg = await HttpUtil.post( + '/panel/api/server/scanRealityTargets', + targets ? { targets } : {}, + { silent: true }, ); + if (!msg?.success || !Array.isArray(msg.obj)) { + messageApi.error(msg?.msg || t('pages.inbounds.toasts.scanRealityTargetError')); + return []; + } + return msg.obj; }; const randomizeShortIds = () => { @@ -209,6 +253,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS }; const onSecurityChange = async (next: string) => { + setScanResult(null); const current = (form.getFieldValue('streamSettings') as Record) ?? {}; const cleaned: Record = { ...current, security: next }; delete cleaned.tlsSettings; @@ -218,9 +263,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS } if (next === 'reality') { const reality = RealityStreamSettingsSchema.parse({}) as Record; - const tgt = getRandomRealityTarget() as { target: string; sni: string }; - reality.target = tgt.target; - reality.serverNames = tgt.sni.split(',').map((s) => s.trim()).filter(Boolean); + reality.target = ''; + reality.serverNames = []; reality.shortIds = RandomUtil.randomShortIds().split(',').map((s) => s.trim()).filter(Boolean); cleaned.realitySettings = reality; } @@ -244,7 +288,9 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS clearRealityKeypair, genMldsa65, clearMldsa65, - randomizeRealityTarget, + scanRealityTarget, + scanRealityCandidates, + applyRealityScanResult, randomizeShortIds, getNewEchCert, clearEchCert, diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx index 53c05d0d1..549099700 100644 --- a/frontend/src/pages/nodes/NodesPage.tsx +++ b/frontend/src/pages/nodes/NodesPage.tsx @@ -41,7 +41,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void }) type="info" showIcon style={{ marginTop: 8 }} - message={t('pages.index.devChannelWarning')} + title={t('pages.index.devChannelWarning')} /> )}
diff --git a/frontend/src/test/inbound-form-blocks.test.tsx b/frontend/src/test/inbound-form-blocks.test.tsx index e5a2b1b47..c25047f9c 100644 --- a/frontend/src/test/inbound-form-blocks.test.tsx +++ b/frontend/src/test/inbound-form-blocks.test.tsx @@ -98,7 +98,11 @@ describe('inbound security forms', () => { renderInForm(() => ( []} + applyRealityScanResult={noop} randomizeShortIds={noop} genRealityKeypair={noop} clearRealityKeypair={noop} diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index 05800c8f2..fae1db3b8 100644 --- a/internal/web/controller/server.go +++ b/internal/web/controller/server.go @@ -78,6 +78,8 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.POST("/getNewEchCert", a.getNewEchCert) g.POST("/getCertHash", a.getCertHash) g.POST("/getRemoteCertHash", a.getRemoteCertHash) + g.POST("/scanRealityTarget", a.scanRealityTarget) + g.POST("/scanRealityTargets", a.scanRealityTargets) g.POST("/clientIps", a.setClientIps) } @@ -445,6 +447,29 @@ func (a *ServerController) getRemoteCertHash(c *gin.Context) { jsonObj(c, hashes, nil) } +// scanRealityTarget runs a live TLS 1.3 probe against the candidate REALITY +// target and returns a structured feasibility verdict plus the cert SAN names. +func (a *ServerController) scanRealityTarget(c *gin.Context) { + res, err := a.serverService.ScanRealityTarget(c.PostForm("target")) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err) + return + } + jsonObj(c, res, nil) +} + +// scanRealityTargets probes a batch of candidate REALITY targets (the supplied +// comma-separated list, or the built-in seed set when empty) and returns each +// verdict ranked by feasibility then latency. +func (a *ServerController) scanRealityTargets(c *gin.Context) { + res, err := a.serverService.ScanRealityTargets(c.PostForm("targets")) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err) + return + } + jsonObj(c, res, nil) +} + // getNewVlessEnc generates a new VLESS encryption key. func (a *ServerController) getNewVlessEnc(c *gin.Context) { out, err := a.serverService.GetNewVlessEnc() diff --git a/internal/web/service/reality_scan.go b/internal/web/service/reality_scan.go new file mode 100644 index 000000000..f3965970c --- /dev/null +++ b/internal/web/service/reality_scan.go @@ -0,0 +1,391 @@ +package service + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/util/common" + "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" +) + +const ( + realityScanTimeout = 10 * time.Second + realityDiscoverTimeout = 4 * time.Second + realityScanConcurrency = 32 + realityDiscoverMaxIPs = 256 + realityScanMaxTotal = 512 +) + +var defaultRealityScanCandidates = []string{ + "www.cloudflare.com:443", + "www.microsoft.com:443", + "www.amazon.com:443", + "aws.amazon.com:443", + "www.samsung.com:443", + "www.nvidia.com:443", + "www.amd.com:443", + "www.intel.com:443", + "www.sony.com:443", + "dl.google.com:443", +} + +type RealityScanResult struct { + Target string `json:"target" example:"www.cloudflare.com:443"` + Host string `json:"host" example:"www.cloudflare.com"` + IP string `json:"ip" example:"104.16.124.96"` + Port int `json:"port" example:"443"` + Feasible bool `json:"feasible" example:"true"` + TLS13 bool `json:"tls13" example:"true"` + TLSVersion string `json:"tlsVersion" example:"1.3"` + H2 bool `json:"h2" example:"true"` + ALPN string `json:"alpn" example:"h2"` + X25519 bool `json:"x25519" example:"true"` + CurveID string `json:"curveID" example:"X25519"` + CertValid bool `json:"certValid" example:"true"` + CertSubject string `json:"certSubject" example:"cloudflare.com"` + CertIssuer string `json:"certIssuer" example:"Google Trust Services"` + NotAfter string `json:"notAfter" example:"2026-08-01T00:00:00Z"` + ServerNames []string `json:"serverNames"` + LatencyMs int `json:"latencyMs" example:"180"` + Reason string `json:"reason" example:""` +} + +type realityProbeTask struct { + dialHost string + port int + sni string + timeout time.Duration + bulk bool +} + +func tlsVersionName(v uint16) string { + switch v { + case tls.VersionTLS13: + return "1.3" + case tls.VersionTLS12: + return "1.2" + case tls.VersionTLS11: + return "1.1" + case tls.VersionTLS10: + return "1.0" + default: + return "unknown" + } +} + +func realityCurveName(id tls.CurveID) string { + switch id { + case tls.X25519: + return "X25519" + case tls.X25519MLKEM768: + return "X25519MLKEM768" + case tls.CurveP256: + return "P-256" + case tls.CurveP384: + return "P-384" + case tls.CurveP521: + return "P-521" + case 0: + return "" + default: + return fmt.Sprintf("0x%04x", uint16(id)) + } +} + +func filterUsableSANs(dnsNames []string) []string { + out := make([]string, 0, len(dnsNames)) + for _, n := range dnsNames { + n = strings.TrimSpace(n) + if n == "" || strings.HasPrefix(n, "*.") { + continue + } + out = append(out, n) + } + return out +} + +func firstUsableName(leaf *x509.Certificate) string { + cn := strings.TrimSpace(leaf.Subject.CommonName) + if cn != "" && !strings.HasPrefix(cn, "*.") { + return cn + } + for _, n := range leaf.DNSNames { + n = strings.TrimSpace(n) + if n != "" && !strings.HasPrefix(n, "*.") { + return n + } + } + return "" +} + +func splitRealityTarget(target string) (string, int, error) { + target = strings.TrimSpace(target) + if target == "" { + return "", 0, common.NewError("target is required") + } + host, portStr := target, "443" + if h, p, err := net.SplitHostPort(target); err == nil { + host, portStr = h, p + } + host, err := netsafe.NormalizeHost(host) + if err != nil { + return "", 0, common.NewError("invalid target host: ", err) + } + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + return "", 0, common.NewError("invalid target port") + } + return host, port, nil +} + +func incIP(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] > 0 { + break + } + } +} + +func enumerateCIDR(cidr string, max int) ([]string, error) { + _, ipnet, err := net.ParseCIDR(strings.TrimSpace(cidr)) + if err != nil { + return nil, err + } + ips := make([]string, 0, max) + for ip := ipnet.IP.Mask(ipnet.Mask); ipnet.Contains(ip); incIP(ip) { + ips = append(ips, ip.String()) + if len(ips) >= max { + break + } + } + return ips, nil +} + +func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration) *RealityScanResult { + addr := net.JoinHostPort(dialHost, strconv.Itoa(port)) + res := &RealityScanResult{Port: port} + if net.ParseIP(dialHost) != nil { + res.IP = dialHost + } + if sni != "" { + res.Host = sni + res.Target = net.JoinHostPort(sni, strconv.Itoa(port)) + } else { + res.Host = dialHost + res.Target = addr + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + start := time.Now() + conn, err := netsafe.SSRFGuardedDialContext(ctx, "tcp", addr) + if err != nil { + res.Reason = "connection failed: " + err.Error() + return res + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + cfg := &tls.Config{ + ServerName: sni, + InsecureSkipVerify: true, + NextProtos: []string{"h2", "http/1.1"}, + CurvePreferences: []tls.CurveID{tls.X25519, tls.X25519MLKEM768}, + MinVersion: tls.VersionTLS12, + } + tlsConn := tls.Client(conn, cfg) + if err := tlsConn.HandshakeContext(ctx); err != nil { + res.Reason = "TLS handshake failed: " + err.Error() + return res + } + res.LatencyMs = int(time.Since(start).Milliseconds()) + + st := tlsConn.ConnectionState() + res.TLS13 = st.Version == tls.VersionTLS13 + res.TLSVersion = tlsVersionName(st.Version) + res.ALPN = st.NegotiatedProtocol + res.H2 = st.NegotiatedProtocol == "h2" + res.CurveID = realityCurveName(st.CurveID) + res.X25519 = st.CurveID == tls.X25519 || st.CurveID == tls.X25519MLKEM768 + + verifyHost := sni + if len(st.PeerCertificates) > 0 { + leaf := st.PeerCertificates[0] + res.CertSubject = leaf.Subject.CommonName + if res.CertSubject == "" && len(leaf.DNSNames) > 0 { + res.CertSubject = leaf.DNSNames[0] + } + if len(leaf.Issuer.Organization) > 0 { + res.CertIssuer = leaf.Issuer.Organization[0] + } else { + res.CertIssuer = leaf.Issuer.CommonName + } + res.NotAfter = leaf.NotAfter.UTC().Format(time.RFC3339) + res.ServerNames = filterUsableSANs(leaf.DNSNames) + + if sni == "" { + if discovered := firstUsableName(leaf); discovered != "" { + res.Host = discovered + res.Target = net.JoinHostPort(discovered, strconv.Itoa(port)) + verifyHost = discovered + } + } + + if verifyHost != "" { + opts := x509.VerifyOptions{DNSName: verifyHost, Intermediates: x509.NewCertPool()} + for _, c := range st.PeerCertificates[1:] { + opts.Intermediates.AddCert(c) + } + if _, verr := leaf.Verify(opts); verr == nil { + res.CertValid = true + } else { + res.Reason = "certificate not trusted: " + verr.Error() + } + } else { + res.Reason = "no usable domain in certificate" + } + } else { + res.Reason = "no certificate presented" + } + + res.Feasible = res.TLS13 && res.H2 && res.X25519 && res.CertValid + if !res.Feasible && res.Reason == "" { + switch { + case !res.TLS13: + res.Reason = "server does not negotiate TLS 1.3" + case !res.H2: + res.Reason = "server does not negotiate HTTP/2 (h2)" + case !res.X25519: + res.Reason = "server did not use X25519 key exchange" + } + } + return res +} + +func (s *ServerService) probeRealityTarget(host string, port int) *RealityScanResult { + return s.probeRealityAddr(host, port, host, realityScanTimeout) +} + +func (s *ServerService) ScanRealityTarget(target string) (*RealityScanResult, error) { + host, port, err := splitRealityTarget(target) + if err != nil { + return nil, err + } + return s.probeRealityTarget(host, port), nil +} + +func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) { + var tokens []string + for _, raw := range strings.Split(targetsCSV, ",") { + if t := strings.TrimSpace(raw); t != "" { + tokens = append(tokens, t) + } + } + if len(tokens) == 0 { + tokens = append(tokens, defaultRealityScanCandidates...) + } + + var tasks []realityProbeTask + var invalid []*RealityScanResult + for _, token := range tokens { + if len(tasks) >= realityScanMaxTotal { + break + } + if strings.Contains(token, "/") { + ips, err := enumerateCIDR(token, realityDiscoverMaxIPs) + if err != nil { + invalid = append(invalid, &RealityScanResult{Target: token, Reason: "invalid CIDR: " + err.Error()}) + continue + } + for _, ip := range ips { + if len(tasks) >= realityScanMaxTotal { + break + } + tasks = append(tasks, realityProbeTask{dialHost: ip, port: 443, timeout: realityDiscoverTimeout, bulk: true}) + } + continue + } + host, port, err := splitRealityTarget(token) + if err != nil { + invalid = append(invalid, &RealityScanResult{Target: token, Reason: err.Error()}) + continue + } + if net.ParseIP(host) != nil { + tasks = append(tasks, realityProbeTask{dialHost: host, port: port, timeout: realityDiscoverTimeout}) + } else { + tasks = append(tasks, realityProbeTask{dialHost: host, port: port, sni: host, timeout: realityScanTimeout}) + } + } + + probed := make([]*RealityScanResult, len(tasks)) + sem := make(chan struct{}, realityScanConcurrency) + var wg sync.WaitGroup + for i, task := range tasks { + wg.Add(1) + sem <- struct{}{} + go func(idx int, tk realityProbeTask) { + defer wg.Done() + defer func() { <-sem }() + r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout) + if tk.bulk && r.TLSVersion == "" { + return + } + probed[idx] = r + }(i, task) + } + wg.Wait() + + results := dedupRealityResults(append(probed, invalid...)) + sortRealityResults(results) + return results, nil +} + +func dedupRealityResults(results []*RealityScanResult) []*RealityScanResult { + best := make(map[string]*RealityScanResult) + order := make([]string, 0, len(results)) + for _, r := range results { + if r == nil { + continue + } + if ex, ok := best[r.Target]; !ok { + best[r.Target] = r + order = append(order, r.Target) + } else if betterRealityResult(r, ex) { + best[r.Target] = r + } + } + out := make([]*RealityScanResult, 0, len(order)) + for _, k := range order { + out = append(out, best[k]) + } + return out +} + +func betterRealityResult(a, b *RealityScanResult) bool { + if a.Feasible != b.Feasible { + return a.Feasible + } + return a.LatencyMs > 0 && (b.LatencyMs == 0 || a.LatencyMs < b.LatencyMs) +} + +func sortRealityResults(results []*RealityScanResult) { + slices.SortStableFunc(results, func(a, b *RealityScanResult) int { + if a.Feasible != b.Feasible { + if a.Feasible { + return -1 + } + return 1 + } + return a.LatencyMs - b.LatencyMs + }) +} diff --git a/internal/web/service/reality_scan_test.go b/internal/web/service/reality_scan_test.go new file mode 100644 index 000000000..e93c99fea --- /dev/null +++ b/internal/web/service/reality_scan_test.go @@ -0,0 +1,111 @@ +package service + +import ( + "crypto/tls" + "testing" +) + +func TestTLSVersionName(t *testing.T) { + cases := map[uint16]string{ + tls.VersionTLS13: "1.3", + tls.VersionTLS12: "1.2", + tls.VersionTLS11: "1.1", + tls.VersionTLS10: "1.0", + 0: "unknown", + } + for in, want := range cases { + if got := tlsVersionName(in); got != want { + t.Errorf("tlsVersionName(%d) = %q, want %q", in, got, want) + } + } +} + +func TestRealityCurveName(t *testing.T) { + cases := map[tls.CurveID]string{ + tls.X25519: "X25519", + tls.X25519MLKEM768: "X25519MLKEM768", + tls.CurveP256: "P-256", + 0: "", + } + for in, want := range cases { + if got := realityCurveName(in); got != want { + t.Errorf("realityCurveName(%d) = %q, want %q", in, got, want) + } + } +} + +func TestFilterUsableSANs(t *testing.T) { + got := filterUsableSANs([]string{"example.com", "*.example.com", "", " a.com "}) + want := []string{"example.com", "a.com"} + if len(got) != len(want) { + t.Fatalf("filterUsableSANs = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("filterUsableSANs[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestSplitRealityTarget(t *testing.T) { + okCases := []struct { + in string + wantHost string + wantPort int + }{ + {"example.com", "example.com", 443}, + {"example.com:8443", "example.com", 8443}, + {"1.1.1.1:443", "1.1.1.1", 443}, + } + for _, c := range okCases { + host, port, err := splitRealityTarget(c.in) + if err != nil { + t.Errorf("splitRealityTarget(%q) unexpected error: %v", c.in, err) + continue + } + if host != c.wantHost || port != c.wantPort { + t.Errorf("splitRealityTarget(%q) = (%q, %d), want (%q, %d)", c.in, host, port, c.wantHost, c.wantPort) + } + } + + badCases := []string{"", " ", "example.com:0", "example.com:70000", "bad host!"} + for _, in := range badCases { + if _, _, err := splitRealityTarget(in); err == nil { + t.Errorf("splitRealityTarget(%q) expected error, got nil", in) + } + } +} + +func TestScanRealityTargetInputValidation(t *testing.T) { + if _, err := (&ServerService{}).ScanRealityTarget(""); err == nil { + t.Error("ScanRealityTarget(empty) expected error, got nil") + } +} + +func TestScanRealityTargetBlocksPrivate(t *testing.T) { + res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443") + if err != nil { + t.Fatalf("ScanRealityTarget(loopback) unexpected error: %v", err) + } + if res.Feasible { + t.Error("ScanRealityTarget(loopback) should not be feasible") + } + if res.Reason == "" { + t.Error("ScanRealityTarget(loopback) should set a reason") + } +} + +func TestScanRealityTargetsHandlesPrivateAndBadInput(t *testing.T) { + results, err := (&ServerService{}).ScanRealityTargets("127.0.0.1:443,10.0.0.1:443,bad host!") + if err != nil { + t.Fatalf("ScanRealityTargets unexpected error: %v", err) + } + if len(results) != 3 { + t.Fatalf("ScanRealityTargets returned %d results, want 3", len(results)) + } + for _, r := range results { + if r.Feasible { + t.Errorf("result %q unexpectedly feasible", r.Target) + } + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index ce6485231..c56c03307 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "حدث خطأ أثناء الحصول على شهادة X25519.", "getNewmldsa65Error": "حدث خطاء في الحصول على mldsa65.", "getNewVlessEncError": "حدث خطأ أثناء الحصول على VlessEnc.", + "scanRealityTargetError": "فشل فحص هدف REALITY.", + "scanRealityTargetFeasible": "الهدف مناسب — تم ملء الهدف وSNI.", + "scanRealityTargetNotFeasible": "الهدف قابل للوصول لكنه غير مناسب لـ REALITY.", "invalidClientField": "العميل {client}: الحقل {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} أخرى)" @@ -623,6 +626,20 @@ "realityTargetRequired": "هدف REALITY مطلوب", "realityTargetNeedsPort": "يجب أن يتضمّن هدف REALITY منفذًا (مثل example.com:443)", "realityTargetInvalidPort": "هدف REALITY يحتوي على منفذ غير صالح", + "scan": "فحص", + "findTargets": "البحث عن أهداف", + "scanModalTitle": "ماسح أهداف REALITY", + "scanModalDesc": "تحقق من نطاق، أو افحص نطاق IP / CIDR لاكتشاف أهداف REALITY جديدة من شهاداتها. اترك الحقل فارغًا لفحص المرشحين الشائعين.", + "scanDiscoverPlaceholder": "IP أو CIDR أو نطاق — اتركه فارغًا للمرشحين الشائعين", + "scanStatus": "الحالة", + "scanFeasible": "مناسب", + "scanNotFeasible": "غير مناسب", + "scanCurve": "تبادل المفاتيح", + "scanCert": "الشهادة", + "scanCertInvalid": "غير موثوق", + "scanLatency": "زمن الاستجابة", + "scanUse": "استخدام", + "scanRescan": "إعادة الفحص", "spiderX": "SpiderX", "getNewCert": "احصل على شهادة جديدة", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index b66a2e28a..c53b0e1e0 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "Error while obtaining the X25519 certificate.", "getNewmldsa65Error": "Error while obtaining mldsa65.", "getNewVlessEncError": "Error while obtaining VlessEnc.", + "scanRealityTargetError": "Failed to scan REALITY target.", + "scanRealityTargetFeasible": "Target is feasible — filled target and SNI.", + "scanRealityTargetNotFeasible": "Target is reachable but not feasible for REALITY.", "invalidClientField": "Client {client}: {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} more)" @@ -635,6 +638,20 @@ "realityTargetRequired": "REALITY target is required", "realityTargetNeedsPort": "REALITY target must include a port (e.g. example.com:443)", "realityTargetInvalidPort": "REALITY target has an invalid port", + "scan": "Scan", + "findTargets": "Find Targets", + "scanModalTitle": "REALITY Target Scanner", + "scanModalDesc": "Validate a domain, or scan an IP / CIDR range to discover new REALITY targets from their certificates. Leave the box empty to probe common candidates.", + "scanDiscoverPlaceholder": "IP, CIDR, or domain — leave empty for common candidates", + "scanStatus": "Status", + "scanFeasible": "Feasible", + "scanNotFeasible": "Not feasible", + "scanCurve": "Key Exchange", + "scanCert": "Certificate", + "scanCertInvalid": "Not trusted", + "scanLatency": "Latency", + "scanUse": "Use", + "scanRescan": "Rescan", "spiderX": "SpiderX", "getNewCert": "Get New Cert", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index addc03760..0eab8e614 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "Error al obtener el certificado X25519.", "getNewmldsa65Error": "Error al obtener el certificado mldsa65.", "getNewVlessEncError": "Error al obtener el certificado VlessEnc.", + "scanRealityTargetError": "No se pudo escanear el objetivo REALITY.", + "scanRealityTargetFeasible": "El objetivo es apto: se rellenaron el objetivo y el SNI.", + "scanRealityTargetNotFeasible": "El objetivo es accesible pero no apto para REALITY.", "invalidClientField": "Cliente {client}: campo {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} más)" @@ -644,6 +647,20 @@ "realityTargetRequired": "El destino REALITY es obligatorio", "realityTargetNeedsPort": "El destino REALITY debe incluir un puerto (p. ej. example.com:443)", "realityTargetInvalidPort": "El destino REALITY tiene un puerto no válido", + "scan": "Escanear", + "findTargets": "Buscar objetivos", + "scanModalTitle": "Escáner de objetivos REALITY", + "scanModalDesc": "Valida un dominio o escanea un rango IP / CIDR para descubrir nuevos objetivos REALITY a partir de sus certificados. Deja el campo vacío para probar los candidatos comunes.", + "scanDiscoverPlaceholder": "IP, CIDR o dominio — déjalo vacío para candidatos comunes", + "scanStatus": "Estado", + "scanFeasible": "Apto", + "scanNotFeasible": "No apto", + "scanCurve": "Intercambio de claves", + "scanCert": "Certificado", + "scanCertInvalid": "No confiable", + "scanLatency": "Latencia", + "scanUse": "Usar", + "scanRescan": "Reescanear", "spiderX": "SpiderX", "getNewCert": "Obtener nuevo cert", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 3d34a72e6..4037c92e3 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "خطا در دریافت گواهی X25519.", "getNewmldsa65Error": "خطا در دریافت گواهی mldsa65.", "getNewVlessEncError": "خطا در دریافت گواهی VlessEnc.", + "scanRealityTargetError": "اسکن هدف REALITY ناموفق بود.", + "scanRealityTargetFeasible": "هدف مناسب است — هدف و SNI پر شد.", + "scanRealityTargetNotFeasible": "هدف در دسترس است اما برای REALITY مناسب نیست.", "invalidClientField": "کلاینت {client}: فیلد {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} مورد دیگر)" @@ -635,6 +638,20 @@ "realityTargetRequired": "هدف REALITY الزامی است", "realityTargetNeedsPort": "هدف REALITY باید شامل پورت باشد (مثلاً example.com:443)", "realityTargetInvalidPort": "پورت هدف REALITY نامعتبر است", + "scan": "اسکن", + "findTargets": "یافتن هدف‌ها", + "scanModalTitle": "اسکنر هدف REALITY", + "scanModalDesc": "یک دامنه را اعتبارسنجی کنید، یا یک محدوده‌ی IP/CIDR را اسکن کنید تا هدف‌های جدید REALITY از روی گواهی‌هایشان کشف شوند. برای بررسی کاندیدهای پیش‌فرض، کادر را خالی بگذارید.", + "scanDiscoverPlaceholder": "آی‌پی، CIDR یا دامنه — برای کاندیدهای پیش‌فرض خالی بگذارید", + "scanStatus": "وضعیت", + "scanFeasible": "مناسب", + "scanNotFeasible": "نامناسب", + "scanCurve": "تبادل کلید", + "scanCert": "گواهی", + "scanCertInvalid": "نامعتبر", + "scanLatency": "تأخیر", + "scanUse": "استفاده", + "scanRescan": "اسکن مجدد", "spiderX": "SpiderX", "getNewCert": "دریافت گواهی جدید", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 62aec08ba..3d0825f8e 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "Terjadi kesalahan saat mendapatkan sertifikat X25519.", "getNewmldsa65Error": "Terjadi kesalahan saat mendapatkan sertifikat mldsa65.", "getNewVlessEncError": "Terjadi kesalahan saat mendapatkan sertifikat VlessEnc.", + "scanRealityTargetError": "Gagal memindai target REALITY.", + "scanRealityTargetFeasible": "Target layak — target dan SNI terisi.", + "scanRealityTargetNotFeasible": "Target dapat dijangkau tetapi tidak layak untuk REALITY.", "invalidClientField": "Klien {client}: kolom {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} lainnya)" @@ -623,6 +626,20 @@ "realityTargetRequired": "Target REALITY wajib diisi", "realityTargetNeedsPort": "Target REALITY harus menyertakan port (mis. example.com:443)", "realityTargetInvalidPort": "Target REALITY memiliki port yang tidak valid", + "scan": "Pindai", + "findTargets": "Cari Target", + "scanModalTitle": "Pemindai Target REALITY", + "scanModalDesc": "Validasi domain, atau pindai rentang IP / CIDR untuk menemukan target REALITY baru dari sertifikatnya. Biarkan kosong untuk memeriksa kandidat umum.", + "scanDiscoverPlaceholder": "IP, CIDR, atau domain — kosongkan untuk kandidat umum", + "scanStatus": "Status", + "scanFeasible": "Layak", + "scanNotFeasible": "Tidak layak", + "scanCurve": "Pertukaran Kunci", + "scanCert": "Sertifikat", + "scanCertInvalid": "Tidak tepercaya", + "scanLatency": "Latensi", + "scanUse": "Gunakan", + "scanRescan": "Pindai ulang", "spiderX": "SpiderX", "getNewCert": "Dapatkan sertifikat baru", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index eaf6b7115..cc8245fe9 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "X25519証明書の取得中にエラーが発生しました。", "getNewmldsa65Error": "mldsa65証明書の取得中にエラーが発生しました。", "getNewVlessEncError": "VlessEnc証明書の取得中にエラーが発生しました。", + "scanRealityTargetError": "REALITY ターゲットのスキャンに失敗しました。", + "scanRealityTargetFeasible": "ターゲットは利用可能です — ターゲットと SNI を入力しました。", + "scanRealityTargetNotFeasible": "ターゲットには到達できますが、REALITY には利用できません。", "invalidClientField": "クライアント {client}: フィールド {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (他 {count} 件)" @@ -644,6 +647,20 @@ "realityTargetRequired": "REALITY ターゲットは必須です", "realityTargetNeedsPort": "REALITY ターゲットにはポートを含める必要があります(例: example.com:443)", "realityTargetInvalidPort": "REALITY ターゲットのポートが無効です", + "scan": "スキャン", + "findTargets": "ターゲットを検索", + "scanModalTitle": "REALITY ターゲットスキャナー", + "scanModalDesc": "ドメインを検証するか、IP / CIDR 範囲をスキャンして証明書から新しい REALITY ターゲットを発見します。空欄のままにすると一般的な候補を検査します。", + "scanDiscoverPlaceholder": "IP、CIDR、またはドメイン — 空欄で一般的な候補", + "scanStatus": "ステータス", + "scanFeasible": "利用可能", + "scanNotFeasible": "利用不可", + "scanCurve": "鍵交換", + "scanCert": "証明書", + "scanCertInvalid": "信頼できません", + "scanLatency": "レイテンシ", + "scanUse": "使用", + "scanRescan": "再スキャン", "spiderX": "SpiderX", "getNewCert": "新しい証明書を取得", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index e99b49923..5e17c0d21 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "Erro ao obter o certificado X25519.", "getNewmldsa65Error": "Erro ao obter o certificado mldsa65.", "getNewVlessEncError": "Erro ao obter o certificado VlessEnc.", + "scanRealityTargetError": "Falha ao escanear o alvo REALITY.", + "scanRealityTargetFeasible": "O alvo é viável — alvo e SNI preenchidos.", + "scanRealityTargetNotFeasible": "O alvo é acessível, mas não é viável para REALITY.", "invalidClientField": "Cliente {client}: campo {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} mais)" @@ -644,6 +647,20 @@ "realityTargetRequired": "O alvo REALITY é obrigatório", "realityTargetNeedsPort": "O alvo REALITY deve incluir uma porta (ex.: example.com:443)", "realityTargetInvalidPort": "O alvo REALITY tem uma porta inválida", + "scan": "Escanear", + "findTargets": "Buscar alvos", + "scanModalTitle": "Scanner de alvos REALITY", + "scanModalDesc": "Valide um domínio ou escaneie um intervalo IP / CIDR para descobrir novos alvos REALITY a partir dos certificados. Deixe vazio para testar os candidatos comuns.", + "scanDiscoverPlaceholder": "IP, CIDR ou domínio — deixe vazio para candidatos comuns", + "scanStatus": "Status", + "scanFeasible": "Viável", + "scanNotFeasible": "Inviável", + "scanCurve": "Troca de chaves", + "scanCert": "Certificado", + "scanCertInvalid": "Não confiável", + "scanLatency": "Latência", + "scanUse": "Usar", + "scanRescan": "Reescanear", "spiderX": "SpiderX", "getNewCert": "Obter novo certificado", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 4c981c531..4d5cc3b65 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "Ошибка при получении сертификата X25519.", "getNewmldsa65Error": "Ошибка при получении сертификата mldsa65.", "getNewVlessEncError": "Ошибка при получении сертификата VlessEnc.", + "scanRealityTargetError": "Не удалось просканировать цель REALITY.", + "scanRealityTargetFeasible": "Цель подходит — поля target и SNI заполнены.", + "scanRealityTargetNotFeasible": "Цель доступна, но не подходит для REALITY.", "invalidClientField": "Клиент {client}: поле {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} ещё)" @@ -644,6 +647,20 @@ "realityTargetRequired": "Цель REALITY обязательна", "realityTargetNeedsPort": "Цель REALITY должна содержать порт (например, example.com:443)", "realityTargetInvalidPort": "У цели REALITY указан недопустимый порт", + "scan": "Сканировать", + "findTargets": "Найти цели", + "scanModalTitle": "Сканер целей REALITY", + "scanModalDesc": "Проверьте домен или просканируйте диапазон IP / CIDR, чтобы обнаружить новые цели REALITY по их сертификатам. Оставьте поле пустым для проверки обычных кандидатов.", + "scanDiscoverPlaceholder": "IP, CIDR или домен — пусто для обычных кандидатов", + "scanStatus": "Статус", + "scanFeasible": "Подходит", + "scanNotFeasible": "Не подходит", + "scanCurve": "Обмен ключами", + "scanCert": "Сертификат", + "scanCertInvalid": "Не доверенный", + "scanLatency": "Задержка", + "scanUse": "Выбрать", + "scanRescan": "Пересканировать", "spiderX": "SpiderX", "getNewCert": "Получить новый сертификат", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 67c0e9fc1..e545d9e97 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.", "getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.", "getNewVlessEncError": "VlessEnc sertifikası alınırken hata oluştu.", + "scanRealityTargetError": "REALITY hedefi taranamadı.", + "scanRealityTargetFeasible": "Hedef uygun — hedef ve SNI dolduruldu.", + "scanRealityTargetNotFeasible": "Hedefe ulaşılabiliyor ancak REALITY için uygun değil.", "invalidClientField": "Kullanıcı {client}: {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} tane daha)" @@ -623,6 +626,20 @@ "realityTargetRequired": "REALITY hedefi zorunludur", "realityTargetNeedsPort": "REALITY hedefi bir port içermelidir (ör. example.com:443)", "realityTargetInvalidPort": "REALITY hedefinde geçersiz bir port var", + "scan": "Tara", + "findTargets": "Hedef bul", + "scanModalTitle": "REALITY Hedef Tarayıcı", + "scanModalDesc": "Bir alan adını doğrulayın veya sertifikalarından yeni REALITY hedefleri keşfetmek için bir IP / CIDR aralığını tarayın. Yaygın adayları taramak için kutuyu boş bırakın.", + "scanDiscoverPlaceholder": "IP, CIDR veya alan adı — yaygın adaylar için boş bırakın", + "scanStatus": "Durum", + "scanFeasible": "Uygun", + "scanNotFeasible": "Uygun değil", + "scanCurve": "Anahtar Değişimi", + "scanCert": "Sertifika", + "scanCertInvalid": "Güvenilmez", + "scanLatency": "Gecikme", + "scanUse": "Kullan", + "scanRescan": "Yeniden tara", "spiderX": "SpiderX", "getNewCert": "Yeni Sertifika Al", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 2e0536a7c..6aa934f84 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "Помилка при отриманні сертифіката X25519.", "getNewmldsa65Error": "Помилка при отриманні сертифіката mldsa65.", "getNewVlessEncError": "Помилка при отриманні сертифіката VlessEnc.", + "scanRealityTargetError": "Не вдалося просканувати ціль REALITY.", + "scanRealityTargetFeasible": "Ціль підходить — поля target і SNI заповнено.", + "scanRealityTargetNotFeasible": "Ціль доступна, але не підходить для REALITY.", "invalidClientField": "Клієнт {client}: поле {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} ще)" @@ -623,6 +626,20 @@ "realityTargetRequired": "Ціль REALITY обов'язкова", "realityTargetNeedsPort": "Ціль REALITY має містити порт (напр., example.com:443)", "realityTargetInvalidPort": "Ціль REALITY має недійсний порт", + "scan": "Сканувати", + "findTargets": "Знайти цілі", + "scanModalTitle": "Сканер цілей REALITY", + "scanModalDesc": "Перевірте домен або проскануйте діапазон IP / CIDR, щоб виявити нові цілі REALITY за їхніми сертифікатами. Залиште поле порожнім для перевірки звичайних кандидатів.", + "scanDiscoverPlaceholder": "IP, CIDR або домен — порожнє для звичайних кандидатів", + "scanStatus": "Статус", + "scanFeasible": "Підходить", + "scanNotFeasible": "Не підходить", + "scanCurve": "Обмін ключами", + "scanCert": "Сертифікат", + "scanCertInvalid": "Ненадійний", + "scanLatency": "Затримка", + "scanUse": "Обрати", + "scanRescan": "Пересканувати", "spiderX": "SpiderX", "getNewCert": "Отримати новий сертифікат", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index b3e8a67f1..91f477a70 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "Lỗi khi lấy chứng chỉ X25519.", "getNewmldsa65Error": "Lỗi khi lấy chứng chỉ mldsa65.", "getNewVlessEncError": "Lỗi khi lấy chứng chỉ VlessEnc.", + "scanRealityTargetError": "Quét mục tiêu REALITY thất bại.", + "scanRealityTargetFeasible": "Mục tiêu khả dụng — đã điền mục tiêu và SNI.", + "scanRealityTargetNotFeasible": "Mục tiêu có thể truy cập nhưng không khả dụng cho REALITY.", "invalidClientField": "Khách hàng {client}: trường {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (+{count} lỗi khác)" @@ -644,6 +647,20 @@ "realityTargetRequired": "Mục tiêu REALITY là bắt buộc", "realityTargetNeedsPort": "Mục tiêu REALITY phải bao gồm cổng (ví dụ example.com:443)", "realityTargetInvalidPort": "Mục tiêu REALITY có cổng không hợp lệ", + "scan": "Quét", + "findTargets": "Tìm mục tiêu", + "scanModalTitle": "Trình quét mục tiêu REALITY", + "scanModalDesc": "Xác thực một tên miền, hoặc quét một dải IP / CIDR để khám phá các mục tiêu REALITY mới từ chứng chỉ của chúng. Để trống để quét các ứng viên phổ biến.", + "scanDiscoverPlaceholder": "IP, CIDR hoặc tên miền — để trống cho ứng viên phổ biến", + "scanStatus": "Trạng thái", + "scanFeasible": "Khả dụng", + "scanNotFeasible": "Không khả dụng", + "scanCurve": "Trao đổi khóa", + "scanCert": "Chứng chỉ", + "scanCertInvalid": "Không tin cậy", + "scanLatency": "Độ trễ", + "scanUse": "Dùng", + "scanRescan": "Quét lại", "spiderX": "SpiderX", "getNewCert": "Lấy chứng chỉ mới", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6f3d94c95..6ab1b9adb 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "获取X25519证书时出错。", "getNewmldsa65Error": "获取mldsa65证书时出错。", "getNewVlessEncError": "获取VlessEnc证书时出错。", + "scanRealityTargetError": "扫描 REALITY 目标失败。", + "scanRealityTargetFeasible": "目标可用 — 已填入目标和 SNI。", + "scanRealityTargetNotFeasible": "目标可达,但不适用于 REALITY。", "invalidClientField": "客户端 {client}:字段 {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (另有 {count} 项)" @@ -643,6 +646,20 @@ "realityTargetRequired": "REALITY 目标为必填项", "realityTargetNeedsPort": "REALITY 目标必须包含端口(例如 example.com:443)", "realityTargetInvalidPort": "REALITY 目标的端口无效", + "scan": "扫描", + "findTargets": "查找目标", + "scanModalTitle": "REALITY 目标扫描器", + "scanModalDesc": "验证某个域名,或扫描 IP / CIDR 范围,从证书中发现新的 REALITY 目标。留空则探测常用候选。", + "scanDiscoverPlaceholder": "IP、CIDR 或域名 — 留空使用常用候选", + "scanStatus": "状态", + "scanFeasible": "可用", + "scanNotFeasible": "不可用", + "scanCurve": "密钥交换", + "scanCert": "证书", + "scanCertInvalid": "不受信任", + "scanLatency": "延迟", + "scanUse": "使用", + "scanRescan": "重新扫描", "spiderX": "SpiderX", "getNewCert": "获取新证书", "mldsa65Seed": "mldsa65 Seed", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 584e0b185..5bd3e2db5 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -474,6 +474,9 @@ "getNewX25519CertError": "取得X25519憑證時發生錯誤。", "getNewmldsa65Error": "取得mldsa65憑證時發生錯誤。", "getNewVlessEncError": "取得VlessEnc憑證時發生錯誤。", + "scanRealityTargetError": "掃描 REALITY 目標失敗。", + "scanRealityTargetFeasible": "目標可用 — 已填入目標與 SNI。", + "scanRealityTargetNotFeasible": "目標可達,但不適用於 REALITY。", "invalidClientField": "用戶端 {client}:欄位 {field} — {reason}", "invalidField": "{field} — {reason}", "moreIssues": "{message} (另有 {count} 項)" @@ -623,6 +626,20 @@ "realityTargetRequired": "REALITY 目標為必填項", "realityTargetNeedsPort": "REALITY 目標必須包含連接埠(例如 example.com:443)", "realityTargetInvalidPort": "REALITY 目標的連接埠無效", + "scan": "掃描", + "findTargets": "尋找目標", + "scanModalTitle": "REALITY 目標掃描器", + "scanModalDesc": "驗證某個網域,或掃描 IP / CIDR 範圍,從憑證中探索新的 REALITY 目標。留空則探測常用候選。", + "scanDiscoverPlaceholder": "IP、CIDR 或網域 — 留空使用常用候選", + "scanStatus": "狀態", + "scanFeasible": "可用", + "scanNotFeasible": "不可用", + "scanCurve": "金鑰交換", + "scanCert": "憑證", + "scanCertInvalid": "不受信任", + "scanLatency": "延遲", + "scanUse": "使用", + "scanRescan": "重新掃描", "spiderX": "SpiderX", "getNewCert": "取得新憑證", "mldsa65Seed": "mldsa65 Seed", diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index fbb5d7303..9d5b7ef1a 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -78,6 +78,7 @@ func run(root, outDir string) error { StructAllow: setOf( "InboundOption", "ProbeResultUI", + "RealityScanResult", ), }, { From 7a2179535ace7418b624007f351ba4a63d0fb515 Mon Sep 17 00:00:00 2001 From: Tomi lla Date: Sat, 27 Jun 2026 16:30:58 +0800 Subject: [PATCH 05/13] fix(settings): normalize API token timestamps (#5599) * fix(settings): normalize API token timestamps * refactor(api-token): share timestamp threshold --------- Co-authored-by: Tomilla <5007859+Tomilla@users.noreply.github.com> --- frontend/src/pages/settings/SecurityTab.tsx | 10 +++- frontend/src/test/api-token-date.test.tsx | 36 ++++++++++++++ internal/database/api_token_timestamp_test.go | 49 +++++++++++++++++++ internal/database/db.go | 12 +++++ internal/database/model/model.go | 6 ++- internal/web/service/panel/api_token.go | 9 +++- internal/web/service/panel/api_token_test.go | 23 +++++++++ 7 files changed, 141 insertions(+), 4 deletions(-) create mode 100644 frontend/src/test/api-token-date.test.tsx create mode 100644 internal/database/api_token_timestamp_test.go create mode 100644 internal/web/service/panel/api_token_test.go diff --git a/frontend/src/pages/settings/SecurityTab.tsx b/frontend/src/pages/settings/SecurityTab.tsx index 7e045268e..5a042f6ad 100644 --- a/frontend/src/pages/settings/SecurityTab.tsx +++ b/frontend/src/pages/settings/SecurityTab.tsx @@ -13,7 +13,7 @@ import { message, } from 'antd'; import { ApiOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons'; -import { ClipboardManager, HttpUtil, RandomUtil } from '@/utils'; +import { ClipboardManager, HttpUtil, IntlUtil, RandomUtil } from '@/utils'; import type { AllSetting } from '@/models/setting'; import { SettingListItem } from '@/components/ui'; import { useMediaQuery } from '@/hooks/useMediaQuery'; @@ -39,6 +39,12 @@ interface SecurityTabProps { updateSetting: (patch: Partial) => void; } +const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000; + +function apiTokenCreatedAtMilliseconds(createdAt: number): number { + return createdAt < UNIX_MILLISECONDS_THRESHOLD ? createdAt * 1000 : createdAt; +} + type TfaType = 'set' | 'confirm'; interface TfaState { @@ -194,7 +200,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr function formatTokenDate(ts: number): string { if (!ts) return ''; - return new Date(ts * 1000).toLocaleString(); + return IntlUtil.formatDate(apiTokenCreatedAtMilliseconds(ts)); } function toggleTwoFactor() { diff --git a/frontend/src/test/api-token-date.test.tsx b/frontend/src/test/api-token-date.test.tsx new file mode 100644 index 000000000..31b75befa --- /dev/null +++ b/frontend/src/test/api-token-date.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { AllSetting } from '@/models/setting'; +import SecurityTab from '@/pages/settings/SecurityTab'; +import { HttpUtil } from '@/utils'; + +describe('API token creation date', () => { + it('renders both API seconds and legacy millisecond timestamps', async () => { + vi.spyOn(HttpUtil, 'get').mockResolvedValueOnce({ + success: true, + msg: '', + obj: [ + { + id: 2, + name: 'seconds-token', + enabled: true, + createdAt: 1782485394, + }, + { + id: 3, + name: 'legacy-milliseconds-token', + enabled: true, + createdAt: 1782485394270, + }, + ], + }); + + render(); + fireEvent.click(screen.getByRole('tab', { name: /API Token/ })); + + expect(await screen.findByText('seconds-token')).toBeTruthy(); + expect(screen.getByText('legacy-milliseconds-token')).toBeTruthy(); + expect(screen.getAllByText(/2026/)).toHaveLength(2); + }); +}); diff --git a/internal/database/api_token_timestamp_test.go b/internal/database/api_token_timestamp_test.go new file mode 100644 index 000000000..5bfa471b6 --- /dev/null +++ b/internal/database/api_token_timestamp_test.go @@ -0,0 +1,49 @@ +package database + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestNormalizeApiTokenCreatedAtSeconds(t *testing.T) { + originalDB := db + t.Cleanup(func() { db = originalDB }) + + var err error + db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.ApiToken{}); err != nil { + t.Fatalf("migrate api_tokens: %v", err) + } + + rows := []model.ApiToken{ + {Name: "seconds", Token: "a", CreatedAt: 1_782_485_394}, + {Name: "milliseconds", Token: "b", CreatedAt: 1_782_485_394_270}, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("seed api tokens: %v", err) + } + + if err := normalizeApiTokenCreatedAtSeconds(); err != nil { + t.Fatalf("normalize timestamps: %v", err) + } + if err := normalizeApiTokenCreatedAtSeconds(); err != nil { + t.Fatalf("normalize timestamps again: %v", err) + } + + var got []model.ApiToken + if err := db.Order("id asc").Find(&got).Error; err != nil { + t.Fatalf("read api tokens: %v", err) + } + for _, row := range got { + if row.CreatedAt != 1_782_485_394 { + t.Fatalf("%s created_at = %d, want seconds", row.Name, row.CreatedAt) + } + } +} diff --git a/internal/database/db.go b/internal/database/db.go index 55589d784..ef21409e2 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -94,6 +94,9 @@ func initModels() error { if err := migrateHostVerifyPeerCertByNameColumn(); err != nil { return err } + if err := normalizeApiTokenCreatedAtSeconds(); err != nil { + return err + } if err := dropLegacyForeignKeys(); err != nil { return err } @@ -1085,6 +1088,15 @@ func InitDB(dbPath string) error { return runSeeders(isUsersEmpty) } +// normalizeApiTokenCreatedAtSeconds repairs rows written while ApiToken used +// autoCreateTime:milli. The threshold separates modern Unix milliseconds from +// Unix seconds and makes this safe to run on every startup. +func normalizeApiTokenCreatedAtSeconds() error { + return db.Model(&model.ApiToken{}). + Where("created_at >= ?", model.ApiTokenUnixMillisecondsThreshold). + UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error +} + // sqliteSynchronous returns the SQLite synchronous mode, defaulting to FULL. // Whitelisted because the value is interpolated directly into a PRAGMA string. func sqliteSynchronous() string { diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 0b5604298..a39d3b58a 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -149,12 +149,16 @@ type HistoryOfSeeders struct { SeederName string `json:"seederName"` } +// ApiTokenUnixMillisecondsThreshold separates legacy millisecond timestamps +// from the seconds-based API token timestamp contract. +const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000 + type ApiToken struct { Id int `json:"id" gorm:"primaryKey;autoIncrement"` Name string `json:"name" gorm:"uniqueIndex;not null"` Token string `json:"token" gorm:"not null"` // SHA-256 hash; the plaintext is shown only once at creation Enabled bool `json:"enabled" gorm:"default:true"` - CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` + CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime"` } // MarshalJSON emits settings, streamSettings, and sniffing as nested JSON diff --git a/internal/web/service/panel/api_token.go b/internal/web/service/panel/api_token.go index 624de5d54..6360be522 100644 --- a/internal/web/service/panel/api_token.go +++ b/internal/web/service/panel/api_token.go @@ -24,6 +24,13 @@ type ApiTokenView struct { CreatedAt int64 `json:"createdAt" example:"1736000000"` } +func apiTokenCreatedAtSeconds(createdAt int64) int64 { + if createdAt >= model.ApiTokenUnixMillisecondsThreshold { + return createdAt / 1000 + } + return createdAt +} + // toView builds the metadata view returned by List. It never carries the // token value: only a SHA-256 hash is stored, and the plaintext is shown // exactly once at creation time. @@ -32,7 +39,7 @@ func toView(t *model.ApiToken) *ApiTokenView { Id: t.Id, Name: t.Name, Enabled: t.Enabled, - CreatedAt: t.CreatedAt, + CreatedAt: apiTokenCreatedAtSeconds(t.CreatedAt), } } diff --git a/internal/web/service/panel/api_token_test.go b/internal/web/service/panel/api_token_test.go new file mode 100644 index 000000000..04f37fcb6 --- /dev/null +++ b/internal/web/service/panel/api_token_test.go @@ -0,0 +1,23 @@ +package panel + +import "testing" + +func TestApiTokenCreatedAtSeconds(t *testing.T) { + tests := []struct { + name string + in int64 + want int64 + }{ + {name: "seconds", in: 1_782_485_394, want: 1_782_485_394}, + {name: "legacy milliseconds", in: 1_782_485_394_270, want: 1_782_485_394}, + {name: "unset", in: 0, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := apiTokenCreatedAtSeconds(tt.in); got != tt.want { + t.Fatalf("apiTokenCreatedAtSeconds(%d) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} From 535b89a352451d8970f8a8f04cbf83836472a548 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 27 Jun 2026 11:15:13 +0200 Subject: [PATCH 06/13] fix(routing): write lowercase L4 network to xray config, display uppercase in UI --- frontend/src/pages/xray/routing/RuleFormModal.tsx | 2 +- frontend/src/pages/xray/routing/helpers.ts | 2 +- frontend/src/pages/xray/routing/useRoutingColumns.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/xray/routing/RuleFormModal.tsx b/frontend/src/pages/xray/routing/RuleFormModal.tsx index 1ce9fc62b..9f88529b1 100644 --- a/frontend/src/pages/xray/routing/RuleFormModal.tsx +++ b/frontend/src/pages/xray/routing/RuleFormModal.tsx @@ -55,7 +55,7 @@ const initialForm = (): FormState => ({ balancerTag: '', }); -const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP']; +const NETWORKS = ['', 'tcp', 'udp', 'tcp,udp']; const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic']; function csv(value: string): string[] { diff --git a/frontend/src/pages/xray/routing/helpers.ts b/frontend/src/pages/xray/routing/helpers.ts index c332cb3dd..0f3f45172 100644 --- a/frontend/src/pages/xray/routing/helpers.ts +++ b/frontend/src/pages/xray/routing/helpers.ts @@ -83,7 +83,7 @@ export function ruleCriteriaChips(rule: RuleRow) { if (rule.port) chips.push({ label: 'Port', value: rule.port }); if (rule.sourceIP) chips.push({ label: 'Src IP', value: rule.sourceIP }); if (rule.sourcePort) chips.push({ label: 'Src Port', value: rule.sourcePort }); - if (rule.network) chips.push({ label: 'L4', value: rule.network }); + if (rule.network) chips.push({ label: 'L4', value: rule.network.toUpperCase() }); if (rule.protocol) chips.push({ label: 'Protocol', value: rule.protocol }); if (rule.user) chips.push({ label: 'User', value: rule.user }); if (rule.vlessRoute) chips.push({ label: 'VLESS', value: rule.vlessRoute }); diff --git a/frontend/src/pages/xray/routing/useRoutingColumns.tsx b/frontend/src/pages/xray/routing/useRoutingColumns.tsx index 418e5401b..cb5f6dbbb 100644 --- a/frontend/src/pages/xray/routing/useRoutingColumns.tsx +++ b/frontend/src/pages/xray/routing/useRoutingColumns.tsx @@ -133,7 +133,7 @@ export function useRoutingColumns({ key: 'network', render: (_v, record) => (
- {record.network && } + {record.network && } {record.protocol && } {record.attrs && } {!record.network && !record.protocol && !record.attrs && } From 439245d42b2f40f2da160e25f3cc9f33f89f0e06 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 27 Jun 2026 11:22:45 +0200 Subject: [PATCH 07/13] feat(inbounds): apply remark template to Export all inbound links Export-all now renders links through the subscription engine via a new GET /panel/api/inbounds/allLinks endpoint, so the configured remark template (name-only display part) is applied per client -- matching the client info/QR pages. Previously it generated links client-side with a hardcoded inbound-email remark. Host-aware: managed Host endpoints win over the plain link, so HOST and per-host variants render; duplicate client JSON entries are deduped by email and the list is scoped to the logged-in user. --- frontend/public/openapi.json | 37 +++++++++++++++++ frontend/src/pages/api-docs/endpoints.ts | 8 ++++ frontend/src/pages/inbounds/InboundsPage.tsx | 19 ++------- internal/sub/export_all_links_test.go | 43 ++++++++++++++++++++ internal/sub/links.go | 9 ++++ internal/sub/service.go | 31 ++++++++++++++ internal/web/controller/inbound.go | 14 +++++++ internal/web/service/inbound_sublink.go | 12 ++++++ 8 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 internal/sub/export_all_links_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 33385c8ad..ef0e1fc10 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2715,6 +2715,43 @@ } } }, + "/panel/api/inbounds/allLinks": { + "get": { + "tags": [ + "Inbounds" + ], + "summary": "Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s \"Export all inbound links\" action.", + "operationId": "get_panel_api_inbounds_allLinks", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": [ + "vless://uuid@host:443?security=reality&...#Germany-alice", + "vmess://eyJ2IjoyLC..." + ] + } + } + } + } + } + } + }, "/panel/api/inbounds/get/{id}": { "get": { "tags": [ diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 28f377de6..bca727ee7 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -126,6 +126,14 @@ export const sections: readonly Section[] = [ responseSchema: 'InboundOption', responseSchemaArray: true, }, + { + method: 'GET', + path: '/panel/api/inbounds/allLinks', + summary: + 'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panel’s "Export all inbound links" action.', + response: + '{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#Germany-alice",\n "vmess://eyJ2IjoyLC..."\n ]\n}', + }, { method: 'GET', path: '/panel/api/inbounds/get/:id', diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index e72aaa5e5..f1ce3a624 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -292,21 +292,10 @@ export default function InboundsPage() { }, [subSettings, openText, t]); const exportAllLinks = useCallback(async () => { - const hydrated = await Promise.all( - dbInbounds.map((ib) => hydrateInbound(ib.id).then((r) => r ?? ib)), - ); - const out: string[] = []; - for (const ib of hydrated) { - const projected = checkFallback(ib); - out.push(genInboundLinks({ - inbound: inboundFromDb(projected), - remark: projected.remark, - hostOverride: hostOverrideFor(ib), - fallbackHostname: preferPublicHost(window.location.hostname, subSettings.publicHost), - })); - } - openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: out.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') }); - }, [dbInbounds, hydrateInbound, checkFallback, hostOverrideFor, subSettings.publicHost, openText, t]); + const msg = await HttpUtil.get('/panel/api/inbounds/allLinks'); + const links = msg?.success && Array.isArray(msg.obj) ? (msg.obj as string[]) : []; + openText({ title: t('pages.inbounds.exportAllLinksTitle'), content: links.join('\r\n'), fileName: t('pages.inbounds.exportAllLinksFileName') }); + }, [openText, t]); const exportAllSubs = useCallback(async () => { const hydrated = await Promise.all( diff --git a/internal/sub/export_all_links_test.go b/internal/sub/export_all_links_test.go new file mode 100644 index 000000000..0de6c8b25 --- /dev/null +++ b/internal/sub/export_all_links_test.go @@ -0,0 +1,43 @@ +package sub + +import ( + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// inboundLinks (the "Export all inbound links" path) must render the remark +// template's whole Client token group per client, name-only — the same engine +// the client/QR pages use. +func TestInboundLinks_RemarkTemplateClientTokens(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + settings := `{"clients":[{"id":"11111111-2222-4333-8444-000000000001","email":"john@e","subId":"subABC","comment":"vip","tgId":777,"enable":true}],"decryption":"none"}` + ib := &model.Inbound{ + UserId: 1, Tag: "t", Enable: true, Listen: "203.0.113.5", Port: 4431, + Protocol: model.VLESS, Remark: "Germany", Settings: settings, + StreamSettings: `{"network":"ws","security":"tls","wsSettings":{"path":"/","host":""},"tlsSettings":{"serverName":"sni"}}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + + svc := NewSubService("{{INBOUND}}-{{EMAIL}}-{{COMMENT}}-{{SUB_ID}}-{{TELEGRAM_ID}}-{{SHORT_ID}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D") + svc.PrepareForRequest("req.example.com") + links := svc.inboundLinks(ib) + + if len(links) != 1 { + t.Fatalf("links = %d, want 1: %v", len(links), links) + } + frag := links[0] + for _, want := range []string{"Germany-john", "vip", "subABC", "777", "11111111"} { + if !strings.Contains(frag, want) { + t.Fatalf("remark missing client token %q: %s", want, frag) + } + } + if strings.Contains(frag, "GB") || strings.ContainsRune(frag, '⏳') { + t.Fatalf("display mode must drop the traffic/expiry segments: %s", frag) + } +} diff --git a/internal/sub/links.go b/internal/sub/links.go index 56d565962..e281df7b6 100644 --- a/internal/sub/links.go +++ b/internal/sub/links.go @@ -41,6 +41,15 @@ func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email return splitLinkLines(svc.GetLink(inbound, email)) } +func (p *LinkProvider) LinksForInbounds(host string, inbounds []*model.Inbound) []string { + svc := p.build(host) + var out []string + for _, inbound := range inbounds { + out = append(out, svc.inboundLinks(inbound)...) + } + return out +} + func splitLinkLines(raw string) []string { if raw == "" { return nil diff --git a/internal/sub/service.go b/internal/sub/service.go index f75f15a5a..d93d4c273 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -242,6 +242,37 @@ func (s *SubService) getSubs(subId string) ([]string, []string, int64, xray.Clie return result, emails, lastOnline, traffic, nil } +// inboundLinks builds the share links for every distinct client of one inbound +// the same way getSubs does — managed Host endpoints win over the plain link so +// {{HOST}} and per-host variants render — but across all clients rather than a +// single subId. Dedups duplicate client JSON entries by email (#5134). Backs the +// panel's "Export all inbound links" so it matches the client/QR pages. +func (s *SubService) inboundLinks(inbound *model.Inbound) []string { + clients, err := s.inboundService.GetClients(inbound) + if err != nil { + return nil + } + s.projectThroughFallbackMaster(inbound) + hostEps := s.hostEndpoints(inbound, "raw") + var out []string + seen := make(map[string]struct{}, len(clients)) + for _, client := range clients { + key := strings.ToLower(client.Email) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + var link string + if len(hostEps) > 0 { + link = s.linkFromHosts(inbound, client, hostEps) + } else { + link = s.GetLink(inbound, client.Email) + } + out = append(out, splitLinkLines(link)...) + } + return out +} + // AggregateTrafficByEmails resolves traffic for every email in one // query and folds the rows into a single ClientTraffic + lastOnline. // xray.ClientTraffic.Email is globally unique, so a multi-inbound diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index d6322f56c..fe95b075c 100644 --- a/internal/web/controller/inbound.go +++ b/internal/web/controller/inbound.go @@ -65,6 +65,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.getInbounds) g.GET("/list/slim", a.getInboundsSlim) g.GET("/options", a.getInboundOptions) + g.GET("/allLinks", a.getAllInboundLinks) g.GET("/get/:id", a.getInbound) g.GET("/:id/fallbacks", a.getFallbacks) @@ -104,6 +105,19 @@ func (a *InboundController) getInboundsSlim(c *gin.Context) { jsonObj(c, inbounds, nil) } +// getAllInboundLinks returns every inbound's share links across all clients, +// rendered through the same subscription engine the client pages use so the +// remark template (name-only display part) is applied consistently. +func (a *InboundController) getAllInboundLinks(c *gin.Context) { + user := session.GetLoginUser(c) + links, err := a.inboundService.GetAllInboundLinks(resolveHost(c), user.Id) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) + return + } + jsonObj(c, links, nil) +} + // getInboundOptions returns a lightweight projection of the user's inbounds // (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI. // Avoids shipping per-client settings and traffic stats just to fill a dropdown. diff --git a/internal/web/service/inbound_sublink.go b/internal/web/service/inbound_sublink.go index 6ad24f387..7fe8b886a 100644 --- a/internal/web/service/inbound_sublink.go +++ b/internal/web/service/inbound_sublink.go @@ -8,6 +8,7 @@ import ( type SubLinkProvider interface { SubLinksForSubId(host, subId string) ([]string, error) LinksForClient(host string, inbound *model.Inbound, email string) []string + LinksForInbounds(host string, inbounds []*model.Inbound) []string } var registeredSubLinkProvider SubLinkProvider @@ -23,6 +24,17 @@ func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) { return registeredSubLinkProvider.SubLinksForSubId(host, subId) } +func (s *InboundService) GetAllInboundLinks(host string, userId int) ([]string, error) { + if registeredSubLinkProvider == nil { + return nil, common.NewError("sub link provider not registered") + } + inbounds, err := s.GetInbounds(userId) + if err != nil { + return nil, err + } + return registeredSubLinkProvider.LinksForInbounds(host, inbounds), nil +} + func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, error) { if email == "" { return nil, common.NewError("client email is required") From 797b08cd0709dac399fffee41d8a07f18475c8b5 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 27 Jun 2026 11:46:19 +0200 Subject: [PATCH 08/13] fix(balancers): create burst observer for random/roundRobin with fallbackTag xray-core's Random/RoundRobinStrategy calls RequireFeatures(Observatory) whenever a fallbackTag is set, so a balancer that declares a fallback but has no observatory aborts startup with 'core: not all dependencies are resolved'. syncObservatories never created an observer for these strategies, crashing the core on any load balancer that used a fallback (the default 'random' strategy with a fallbackTag, exactly issue #5605). Treat random/roundRobin balancers that set a fallbackTag as requiring the burst observer. Also make the burst observer strictly requirement-driven (mirroring the leastPing/observatory path) so clearing the last fallbackTag drops it again instead of leaving a dead observer that forces needless restarts and probing. Closes #5605 --- .../pages/xray/balancers/balancer-helpers.ts | 38 ++++++++---- .../test/balancer-observatory-sync.test.ts | 58 ++++++++++++++++++- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/xray/balancers/balancer-helpers.ts b/frontend/src/pages/xray/balancers/balancer-helpers.ts index 44e10e180..31eef3a9a 100644 --- a/frontend/src/pages/xray/balancers/balancer-helpers.ts +++ b/frontend/src/pages/xray/balancers/balancer-helpers.ts @@ -26,14 +26,23 @@ export function collectSelectors(list: BalancerObject[]): string[] { } // syncObservatories keeps the (burst)observatory sections aligned with the -// balancer strategies that actually require them. Observatories have no -// runtime reload API in xray-core, so any change here forces a full process -// restart — that's why random/roundRobin balancers, which work fine without -// an observer, never CREATE one: a plain balancer add/edit then stays a -// routing-only change and applies live through the core API. An already -// existing burstObservatory is still kept in sync for them (alive-only -// filtering keeps working for setups that had it), it's just never the -// reason a new one appears. +// balancer strategies that actually require them. Observatories have no runtime +// reload API in xray-core, so creating OR removing one forces a full process +// restart — that's why an observer-less balancer never gets one and stays a +// live, routing-only change applied through the core API. +// +// xray-core binds the Observatory feature to a Random/RoundRobinStrategy only +// when its fallbackTag is set (issue #5605): with a fallbackTag the strategy +// calls RequireFeatures(Observatory) and the core aborts startup with "not all +// dependencies are resolved" if none exists; without a fallbackTag it never even +// consults an observatory. leastLoad always needs the burst observer, leastPing +// the regular one. +// +// So each observer lives exactly as long as something requires it, and is +// dropped the moment nothing does — clearing the last fallbackTag (or deleting +// the last leastLoad) removes the burst observer again. A no-fallback balancer's +// selector is still probed while the observer exists for another reason, but +// never keeps it alive on its own. export function syncObservatories(t: XraySettingsValue) { const balancers = (t.routing?.balancers || []) as BalancerObject[]; @@ -45,15 +54,20 @@ export function syncObservatories(t: XraySettingsValue) { delete t.observatory; } - const required = balancers.filter((b) => b.strategy?.type === 'leastLoad'); + const hasFallback = (b: BalancerObject) => (b.fallbackTag ?? '').length > 0; + const required = balancers.filter((b) => { + const type = b.strategy?.type || 'random'; + if (type === 'leastLoad') return true; + return (type === 'random' || type === 'roundRobin') && hasFallback(b); + }); const optional = balancers.filter((b) => { const type = b.strategy?.type || 'random'; - return type === 'random' || type === 'roundRobin'; + return (type === 'random' || type === 'roundRobin') && !hasFallback(b); }); - if (required.length > 0 || (optional.length > 0 && t.burstObservatory)) { + if (required.length > 0) { if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY)); (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([...required, ...optional]); - } else if (required.length === 0 && optional.length === 0) { + } else { delete t.burstObservatory; } } diff --git a/frontend/src/test/balancer-observatory-sync.test.ts b/frontend/src/test/balancer-observatory-sync.test.ts index d4d34b736..08c97c784 100644 --- a/frontend/src/test/balancer-observatory-sync.test.ts +++ b/frontend/src/test/balancer-observatory-sync.test.ts @@ -10,7 +10,8 @@ function tpl(routing: Record, extra: Record = // Observatory sections have no reload API in xray-core, so creating one turns // a balancer save from a live (hot-applied) routing change into a full // restart. These tests pin the rule: only strategies that genuinely need an -// observer may create one. +// observer may create one — which, for random/roundRobin, means a fallbackTag +// is set (xray-core then requires the Observatory feature; see #5605). describe('syncObservatories', () => { it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => { const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] }); @@ -19,12 +20,65 @@ describe('syncObservatories', () => { expect(t.observatory).toBeUndefined(); }); - it('does not create burstObservatory for roundRobin', () => { + it('does not create burstObservatory for roundRobin without fallback', () => { const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] }); syncObservatories(t); expect(t.burstObservatory).toBeUndefined(); }); + it('creates burstObservatory for a random balancer with a fallbackTag (#5605)', () => { + const t = tpl({ balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: 'warp' }] }); + syncObservatories(t); + expect(t.burstObservatory).toBeDefined(); + expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['opera-proxy']); + }); + + it('creates burstObservatory for roundRobin with a fallbackTag', () => { + const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } }] }); + syncObservatories(t); + expect(t.burstObservatory).toBeDefined(); + expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']); + }); + + it('treats an empty-string fallbackTag as no fallback (stays hot-appliable)', () => { + const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], fallbackTag: '' }] }); + syncObservatories(t); + expect(t.burstObservatory).toBeUndefined(); + }); + + it('removes burstObservatory when a random balancer drops its fallbackTag', () => { + const t = tpl( + { balancers: [{ tag: 'OverProxy', selector: ['opera-proxy'], fallbackTag: '' }] }, + { burstObservatory: { subjectSelector: ['opera-proxy'] } }, + ); + syncObservatories(t); + expect(t.burstObservatory).toBeUndefined(); + }); + + it('removes burstObservatory when a roundRobin balancer drops its fallbackTag', () => { + const t = tpl( + { balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] }, + { burstObservatory: { subjectSelector: ['a'] } }, + ); + syncObservatories(t); + expect(t.burstObservatory).toBeUndefined(); + }); + + it('keeps burstObservatory while another fallback balancer still needs it', () => { + const t = tpl( + { + balancers: [ + { tag: 'b1', selector: ['a'] }, + { tag: 'b2', selector: ['b'], fallbackTag: 'warp', strategy: { type: 'roundRobin' } }, + ], + }, + { burstObservatory: { subjectSelector: ['a', 'b'] } }, + ); + syncObservatories(t); + expect(t.burstObservatory).toBeDefined(); + expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']); + }); + it('creates burstObservatory for leastLoad (required by the strategy)', () => { const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] }); syncObservatories(t); From 4c177f0cf187602188e8ab001e43e7762d744492 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 27 Jun 2026 12:00:38 +0200 Subject: [PATCH 09/13] fix(shadowsocks): send per-user Account for SS-2022 runtime AddUser SS-2022 user updates passed shadowsocks_2022.ServerConfig (the inbound-level config) as the gRPC user account. The core rejects it with "Unknown account type" because only shadowsocks_2022.Account implements AsAccount(), so live AddUser failed and renewed/reset/added users stayed inactive until the 30s auto-restart rebuilt the inbound from the DB. Use shadowsocks_2022.Account{Key: password} (the per-user type, matching xray-core's own multi-user builder) so changes apply immediately without a restart. Fixes #5597 --- internal/xray/api.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/xray/api.go b/internal/xray/api.go index 96a3dd1fc..921e77a93 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -502,9 +502,8 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an CipherType: ssCipherType, }) } else { - account = serial.ToTypedMessage(&shadowsocks_2022.ServerConfig{ - Key: password, - Email: userEmail, + account = serial.ToTypedMessage(&shadowsocks_2022.Account{ + Key: password, }) } case "hysteria": From 1bad2fcba10f48b6ca10b9870a3fe3ac41c45d51 Mon Sep 17 00:00:00 2001 From: Nikan Zeyaei <72458440+NikanZeyaei@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:38:20 +0330 Subject: [PATCH 10/13] feat(backup): prefix backup filenames with date and time (#5606) * feat(backup): add YYYY-MM-DD_ date prefix to backup filenames Refs #5584 * feat(backup): prefix backup filenames with date and time * fix(backup): put host before date in backup filename Backup filenames now read {host}_{date}{ext} (e.g. panel.example.com_2026-06-27_000000.db) instead of {date}_{host}{ext}, so files group by server first then sort chronologically within each server. --- internal/web/service/backup_filename_test.go | 30 ++++++++++++++++++++ internal/web/service/server.go | 24 +++++++++++----- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/internal/web/service/backup_filename_test.go b/internal/web/service/backup_filename_test.go index 5bc4e563b..44a4fabfd 100644 --- a/internal/web/service/backup_filename_test.go +++ b/internal/web/service/backup_filename_test.go @@ -3,6 +3,7 @@ package service import ( "regexp" "testing" + "time" ) // getDb (controller) only accepts a Content-Disposition filename matching this @@ -36,3 +37,32 @@ func TestSanitizeBackupHost(t *testing.T) { }) } } + +// dateSuffixRegex narrows backupFilenameRegex to the exact _YYYY-MM-DD_HHMMSS shape. +var dateSuffixRegex = regexp.MustCompile(`^_\d{4}-\d{2}-\d{2}_\d{6}$`) + +func TestBackupDateSuffix(t *testing.T) { + cases := []struct { + name string + now time.Time + want string + }{ + {"utc midnight", time.Date(2026, 6, 27, 0, 0, 0, 0, time.UTC), "_2026-06-27_000000"}, + {"end of year", time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC), "_2025-12-31_235959"}, + {"single digit month/day padded", time.Date(2026, 1, 5, 9, 4, 0, 0, time.UTC), "_2026-01-05_090400"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := backupDateSuffix(tc.now) + if got != tc.want { + t.Errorf("backupDateSuffix(%v) = %q, want %q", tc.now, got, tc.want) + } + if !dateSuffixRegex.MatchString(got) { + t.Errorf("backupDateSuffix(%v) = %q, not a valid date suffix", tc.now, got) + } + if !backupFilenameRegex.MatchString(got) { + t.Errorf("backupDateSuffix(%v) = %q, not a valid download filename char", tc.now, got) + } + }) + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index da235a1b2..2d98a2880 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -1298,18 +1298,28 @@ func (s *ServerService) GetDb() ([]byte, error) { // BackupFilename returns the filename for a database backup, named after the // panel's address so a downloaded or Telegram-sent backup identifies the server -// it came from. requestHost is the browser's address: the getDb handler passes -// c.Request.Host so a panel download is named after whatever address the user -// reached the panel with, no Listen Domain needed. The Telegram bot has no -// request and passes "", falling back to the configured Listen Domain (webDomain) -// and then the public IP. The extension is .dump on PostgreSQL and .db on SQLite; -// the base falls back to "x-ui" when no address is known. +// it came from, followed by the current date and time (_YYYY-MM-DD_HHMMSS) so +// files accumulated in Telegram chat history group by server then sort +// chronologically and same-day backups stay distinct. requestHost is the +// browser's address: the getDb handler passes c.Request.Host so a panel download +// is named after whatever address the user reached the panel with, no Listen +// Domain needed. The Telegram bot has no request and passes "", falling back to +// the configured Listen Domain (webDomain) and then the public IP. The extension +// is .dump on PostgreSQL and .db on SQLite; the base falls back to "x-ui" when +// no address is known. func (s *ServerService) BackupFilename(requestHost string) string { ext := ".db" if database.IsPostgres() { ext = ".dump" } - return s.backupHost(requestHost) + ext + return s.backupHost(requestHost) + backupDateSuffix(time.Now()) + ext +} + +// backupDateSuffix returns the _YYYY-MM-DD_HHMMSS chronological suffix appended +// after the host in backup filenames. Uses server-local time for consistency +// with the timestamp printed in the Telegram backup message body. +func backupDateSuffix(now time.Time) string { + return "_" + now.Format("2006-01-02_150405") } // backupHost picks the address used to name backup files: the browser's request From 876d55f2741bed1c3ea62fa328312947ceed05be Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 27 Jun 2026 12:42:12 +0200 Subject: [PATCH 11/13] fix(sub): show {{EMAIL}} on first sub-body link only The remark template's {{EMAIL}}/{{USERNAME}} were repeated on every link of a subscription. Strip them from subsequent body links like the usage tokens, so the email appears once on the first link. Display/QR remarks and the other client tokens are unaffected. --- internal/sub/remark_vars.go | 11 ++++++++++- internal/sub/remark_vars_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/sub/remark_vars.go b/internal/sub/remark_vars.go index 2a93d31e8..23cc3d6fb 100644 --- a/internal/sub/remark_vars.go +++ b/internal/sub/remark_vars.go @@ -484,6 +484,15 @@ var connectionTokens = map[string]bool{ var displayRemoveTokens = mergeTokenSets(usageInfoTokens, connectionTokens) +// firstLinkOnlyBodyTokens are stripped from every subscription-body link after a +// client's first one: the usage/info tokens plus the per-client EMAIL/USERNAME +// identity. A client app needs the email once, so repeating it on every link of +// the same subscription is noise — show it on the first link only, like traffic. +var firstLinkOnlyBodyTokens = mergeTokenSets(usageInfoTokens, map[string]bool{ + "EMAIL": true, + "USERNAME": true, +}) + func mergeTokenSets(sets ...map[string]bool) map[string]bool { out := make(map[string]bool) for _, set := range sets { @@ -554,7 +563,7 @@ func (s *SubService) effectiveTemplate(email string) string { s.usageShown = map[string]bool{} } if s.usageShown[email] { - return filterRemarkTemplate(translated, usageInfoTokens) + return filterRemarkTemplate(translated, firstLinkOnlyBodyTokens) } s.usageShown[email] = true return translated diff --git a/internal/sub/remark_vars_test.go b/internal/sub/remark_vars_test.go index 3b2b709fe..12125c144 100644 --- a/internal/sub/remark_vars_test.go +++ b/internal/sub/remark_vars_test.go @@ -610,3 +610,32 @@ func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) { t.Fatalf("second link must not carry usage: %q", second) } } + +func TestEmailOnFirstLinkOnly(t *testing.T) { + s := &SubService{ + remarkTemplate: "{{INBOUND}} {{EMAIL}}|📊{{TRAFFIC_LEFT}}", + subscriptionBody: true, + usageShown: map[string]bool{}, + } + inbound := &model.Inbound{ + Remark: "DE", + ClientStats: []xray.ClientTraffic{{ + Email: "alice@x", + Enable: true, + Total: 100 * gb, + }}, + } + client := model.Client{Email: "alice@x"} + first := s.genTemplatedRemark(inbound, client, "", "ws") + s.usageShown["alice@x"] = true + second := s.genTemplatedRemark(inbound, client, "", "ws") + if !strings.Contains(first, "alice@x") { + t.Fatalf("first link should carry email: %q", first) + } + if strings.Contains(second, "alice@x") { + t.Fatalf("second link must not carry email: %q", second) + } + if !strings.Contains(second, "DE") { + t.Fatalf("second link should still carry the inbound name: %q", second) + } +} From 39eb5baf420bc1ceb1bd03c5f4b3d020cb7a56de Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 27 Jun 2026 13:50:06 +0200 Subject: [PATCH 12/13] fix(inbound): convert legacy externalProxy to hosts on import An inbound exported from a build that predated the hosts table carries its external proxies inline in streamSettings.externalProxy. The startup migration that converts those to host rows runs once and is gated off afterwards, so it never sees a freshly imported inbound, leaving its external proxies stranded in streamSettings (never surfaced as Hosts). Extract the migration's per-inbound conversion into a shared database.CreateHostsFromExternalProxy and run it inside the AddInbound transaction. No-op for inbounds without externalProxy (everything the current UI builds), so it only fires on such imports. --- internal/database/db.go | 56 ++++++---- internal/web/service/inbound.go | 10 ++ .../inbound_import_external_proxy_test.go | 103 ++++++++++++++++++ 3 files changed, 149 insertions(+), 20 deletions(-) create mode 100644 internal/web/service/inbound_import_external_proxy_test.go diff --git a/internal/database/db.go b/internal/database/db.go index ef21409e2..92b6b405c 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -183,32 +183,48 @@ func seedHostsFromExternalProxy() error { return db.Transaction(func(tx *gorm.DB) error { for _, inbound := range inbounds { - if strings.TrimSpace(inbound.StreamSettings) == "" { - continue - } - var stream map[string]any - if err := json.Unmarshal([]byte(inbound.StreamSettings), &stream); err != nil { - log.Printf("HostsFromExternalProxy: skip inbound %d (invalid stream json): %v", inbound.Id, err) - continue - } - eps, ok := stream["externalProxy"].([]any) - if !ok || len(eps) == 0 { - continue - } - for i, raw := range eps { - ep, ok := raw.(map[string]any) - if !ok { - continue - } - if err := tx.Create(externalProxyEntryToHost(inbound.Id, i, ep)).Error; err != nil { - return err - } + if _, err := CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil { + return err } } return tx.Create(&model.HistoryOfSeeders{SeederName: "HostsFromExternalProxy"}).Error }) } +// CreateHostsFromExternalProxy parses a legacy streamSettings.externalProxy array +// and inserts one Host row per entry on tx, returning the number of rows created. +// It is the shared core of both the one-time seedHostsFromExternalProxy startup +// migration and the inbound-import path: an inbound exported from a build that +// predated the hosts table carries its external proxies inline in +// streamSettings.externalProxy, and the startup migration is gated off after its +// first run, so a freshly imported inbound must be converted here instead. Blank +// or malformed streamSettings, or one without externalProxy entries, is a no-op. +func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings string) (int, error) { + if strings.TrimSpace(streamSettings) == "" { + return 0, nil + } + var stream map[string]any + if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil { + return 0, nil + } + eps, ok := stream["externalProxy"].([]any) + if !ok || len(eps) == 0 { + return 0, nil + } + created := 0 + for i, raw := range eps { + ep, ok := raw.(map[string]any) + if !ok { + continue + } + if err := tx.Create(externalProxyEntryToHost(inboundId, i, ep)).Error; err != nil { + return created, err + } + created++ + } + return created, nil +} + // externalProxyEntryToHost maps one legacy externalProxy entry onto a Host. // forceTls (same|tls|none) maps straight to Security; an unknown value falls back // to "same" (inherit). An empty remark gets a stable generated label so the row diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 286ae4ebd..44ba5d2c8 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -705,6 +705,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo return inbound, false, err } + // Legacy import: an inbound exported from a build that predated the hosts + // table carries its external proxies inline in streamSettings.externalProxy. + // The startup migration that converts those to host rows runs once and is + // gated off afterwards, so it never sees a freshly imported inbound — + // reproduce it here. No-op for inbounds without externalProxy (everything the + // current UI builds), so this only fires on such imports. + if _, err = database.CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil { + return inbound, false, err + } + // Before the deferred commit, so a node in "selected" sync mode cannot // sweep the new central row in the gap before its tag is allowed. if inbound.NodeID != nil { diff --git a/internal/web/service/inbound_import_external_proxy_test.go b/internal/web/service/inbound_import_external_proxy_test.go new file mode 100644 index 000000000..96553b59b --- /dev/null +++ b/internal/web/service/inbound_import_external_proxy_test.go @@ -0,0 +1,103 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// TestAddInbound_ImportConvertsExternalProxyToHosts reproduces the panel report: +// an inbound exported from a build that predated the hosts table carries its +// external proxies inline in streamSettings.externalProxy. The one-time startup +// migration that converts those to host rows is gated off after first run, so a +// freshly imported inbound used to land with zero hosts (its external proxies +// silently lost). AddInbound must convert them on import. +func TestAddInbound_ImportConvertsExternalProxyToHosts(t *testing.T) { + setupConflictDB(t) + svc := &InboundService{} + + stream := `{ + "network":"ws", + "wsSettings":{"path":"/req3","host":"astr.khafanha.ir"}, + "security":"none", + "externalProxy":[ + {"forceTls":"same","dest":"snapp.ir","port":8080,"remark":"","sni":"","alpn":[],"pinnedPeerCertSha256":[],"echConfigList":""}, + {"forceTls":"tls","dest":"cdn.example.com","port":8443,"remark":"front","sni":"sni.example.com","fingerprint":"chrome","alpn":["h2","h3"],"pinnedPeerCertSha256":["AAAA"],"echConfigList":"ECHV"} + ] + }` + settings := `{"clients":[{"id":"6df5616b-ebfd-4186-86d5-4bce29fe8805","email":"imp_user","subId":"s-imp","enable":true}],"decryption":"none","encryption":"none"}` + + in := &model.Inbound{ + UserId: 1, + Tag: "in-8080-tcp", + Enable: true, + Listen: "", + Port: 8080, + Protocol: model.VLESS, + StreamSettings: stream, + Settings: settings, + } + created, _, err := svc.AddInbound(in) + if err != nil { + t.Fatalf("import inbound: %v", err) + } + + var hosts []model.Host + if err := database.GetDB().Where("inbound_id = ?", created.Id).Order("sort_order asc").Find(&hosts).Error; err != nil { + t.Fatalf("load hosts: %v", err) + } + if len(hosts) != 2 { + t.Fatalf("hosts = %d, want 2 (one per externalProxy entry)", len(hosts)) + } + + a := hosts[0] + if a.SortOrder != 0 || a.Security != "same" || a.Address != "snapp.ir" || a.Port != 8080 { + t.Fatalf("host A mapping wrong: %+v", a) + } + if a.Remark == "" { + t.Fatalf("host A remark must be backfilled for a blank externalProxy remark, got empty") + } + + b := hosts[1] + if b.SortOrder != 1 || b.Security != "tls" || b.Address != "cdn.example.com" || b.Port != 8443 || + b.Remark != "front" || b.Sni != "sni.example.com" || b.Fingerprint != "chrome" || b.EchConfigList != "ECHV" { + t.Fatalf("host B mapping wrong: %+v", b) + } + if len(b.Alpn) != 2 || b.Alpn[0] != "h2" || b.Alpn[1] != "h3" { + t.Fatalf("host B alpn = %v, want [h2 h3]", b.Alpn) + } + if len(b.PinnedPeerCertSha256) != 1 || b.PinnedPeerCertSha256[0] != "AAAA" { + t.Fatalf("host B pins = %v, want [AAAA]", b.PinnedPeerCertSha256) + } +} + +// TestAddInbound_NoExternalProxyCreatesNoHosts guards the no-op path: an inbound +// built by the current UI (no externalProxy) must not gain phantom host rows. +func TestAddInbound_NoExternalProxyCreatesNoHosts(t *testing.T) { + setupConflictDB(t) + svc := &InboundService{} + + in := &model.Inbound{ + UserId: 1, + Tag: "in-9201-tcp", + Enable: true, + Listen: "0.0.0.0", + Port: 9201, + Protocol: model.VLESS, + StreamSettings: `{"network":"tcp","security":"none"}`, + Settings: `{"clients":[{"id":"77777777-7777-7777-7777-777777777777","email":"plain","subId":"s-plain","enable":true}],"decryption":"none","encryption":"none"}`, + } + created, _, err := svc.AddInbound(in) + if err != nil { + t.Fatalf("add inbound: %v", err) + } + + var count int64 + if err := database.GetDB().Model(&model.Host{}).Where("inbound_id = ?", created.Id).Count(&count).Error; err != nil { + t.Fatalf("count hosts: %v", err) + } + if count != 0 { + t.Fatalf("host count = %d, want 0", count) + } +} From d12b186a69f1d5f723f6bdf929b7f86a6b5cb355 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 27 Jun 2026 13:56:45 +0200 Subject: [PATCH 13/13] test(sub): align identity-token test with first-link-only EMAIL 876d55f2 made {{EMAIL}}/{{USERNAME}} appear on the first sub-body link only, but TestIdentityTokensEverywhere still asserted the email survived on every repeat body link, breaking the go-test and race CI jobs. Update it to assert the repeat body link drops the identity token while the display/QR remark keeps it; the first-link case is covered by TestEmailOnFirstLinkOnly. --- internal/sub/remark_vars_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/sub/remark_vars_test.go b/internal/sub/remark_vars_test.go index 12125c144..eb254e957 100644 --- a/internal/sub/remark_vars_test.go +++ b/internal/sub/remark_vars_test.go @@ -361,7 +361,7 @@ func TestConnectionTokensDisplayContextUnchanged(t *testing.T) { } } -func TestIdentityTokensEverywhere(t *testing.T) { +func TestIdentityTokenBodyVsDisplay(t *testing.T) { const tmpl = "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|{{EMAIL}}" inbound := &model.Inbound{ Remark: "DE", @@ -373,8 +373,8 @@ func TestIdentityTokensEverywhere(t *testing.T) { body := &SubService{remarkTemplate: tmpl, subscriptionBody: true, usageShown: map[string]bool{}} _ = body.genTemplatedRemark(inbound, client, "", "ws") // first link consumes the usage block - if second := body.genTemplatedRemark(inbound, client, "", "ws"); !strings.Contains(second, "john@x") { - t.Fatalf("repeat body link %q must keep the identity token", second) + if second := body.genTemplatedRemark(inbound, client, "", "ws"); strings.Contains(second, "john@x") { + t.Fatalf("repeat body link %q must drop the identity token", second) } display := &SubService{remarkTemplate: tmpl, subscriptionBody: false}