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..ef0e1fc10 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": { @@ -2617,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": [ @@ -4637,6 +4772,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/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
+ {targetOptions.length === 0 ? ( - + ) : ( <> {targetOptions.length === 0 ? ( - + ) : ( <> {rows.length === 0 ? ( - + ) : ( size="small" 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/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/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 && ( void }) type="info" showIcon style={{ marginTop: 8 }} - message={t('pages.index.devChannelWarning')} + title={t('pages.index.devChannelWarning')} /> )}
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/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/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 && } 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/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); 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/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..92b6b405c 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 } @@ -180,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 @@ -1085,6 +1104,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/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/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/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..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} @@ -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) + } +} 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/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/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/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) + } +} 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") 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) + } + }) + } +} diff --git a/internal/web/service/panel/panel.go b/internal/web/service/panel/panel.go index 0a1edd9bc..e4ed877cc 100644 --- a/internal/web/service/panel/panel.go +++ b/internal/web/service/panel/panel.go @@ -90,12 +90,11 @@ func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) { } // devChannelActive reports whether self-update should track the rolling dev -// release. It requires both the opt-in setting and a dev build, so a stable -// binary with the toggle left on never cross-grades to the dev channel. +// release. It is driven solely by the opt-in setting so the panel can +// cross-grade a stable build onto the dev channel once the user enables it; +// nothing updates without an explicit user action, so an unattended stable +// binary with the toggle off stays on the stable channel. func devChannelActive() bool { - if !config.IsDevBuild() { - return false - } enabled, err := (&service.SettingService{}).GetDevChannelEnable() return err == nil && enabled } 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/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 diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index f0ecd4a64..c56c03307 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -117,7 +117,8 @@ "logout": "تسجيل خروج", "link": "إدارة", "donate": "تبرع", - "hosts": "المضيفات" + "hosts": "المضيفات", + "docs": "التوثيق" }, "pages": { "login": { @@ -473,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} أخرى)" @@ -622,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 71dd24ed5..c53b0e1e0 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -117,7 +117,8 @@ "apiDocs": "API Docs", "logout": "Log Out", "link": "Manage", - "donate": "Donate" + "donate": "Donate", + "docs": "Documentation" }, "pages": { "login": { @@ -473,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)" @@ -634,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 26475fc98..0eab8e614 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -117,7 +117,8 @@ "logout": "Cerrar Sesión", "link": "Gestionar", "donate": "Donar", - "hosts": "Hosts" + "hosts": "Hosts", + "docs": "Documentación" }, "pages": { "login": { @@ -473,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)" @@ -643,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 e8c58dbd2..4037c92e3 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -117,7 +117,8 @@ "logout": "خروج", "link": "مدیریت", "donate": "حمایت مالی", - "hosts": "میزبان‌ها" + "hosts": "میزبان‌ها", + "docs": "مستندات" }, "pages": { "login": { @@ -473,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} مورد دیگر)" @@ -634,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 098477d20..3d0825f8e 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -117,7 +117,8 @@ "logout": "Keluar", "link": "Kelola", "donate": "Donasi", - "hosts": "Host" + "hosts": "Host", + "docs": "Dokumentasi" }, "pages": { "login": { @@ -473,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)" @@ -622,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 2fb7fa2f5..cc8245fe9 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -117,7 +117,8 @@ "logout": "ログアウト", "link": "リンク管理", "donate": "寄付", - "hosts": "ホスト" + "hosts": "ホスト", + "docs": "ドキュメント" }, "pages": { "login": { @@ -473,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 +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 1c67b90e5..5e17c0d21 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -117,7 +117,8 @@ "logout": "Sair", "link": "Gerenciar", "donate": "Doar", - "hosts": "Hosts" + "hosts": "Hosts", + "docs": "Documentação" }, "pages": { "login": { @@ -473,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)" @@ -643,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 9aa9e032a..4d5cc3b65 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -117,7 +117,8 @@ "logout": "Выход", "link": "Управление", "donate": "Поддержать", - "hosts": "Хосты" + "hosts": "Хосты", + "docs": "Документация" }, "pages": { "login": { @@ -473,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} ещё)" @@ -643,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 dcba7f162..e545d9e97 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -117,7 +117,8 @@ "logout": "Çıkış Yap", "link": "Yönet", "donate": "Bağış Yap", - "hosts": "Host'lar" + "hosts": "Host'lar", + "docs": "Belgeler" }, "pages": { "login": { @@ -473,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)" @@ -622,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 8225f50aa..6aa934f84 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -117,7 +117,8 @@ "logout": "Вийти", "link": "Керувати", "donate": "Підтримати", - "hosts": "Хости" + "hosts": "Хости", + "docs": "Документація" }, "pages": { "login": { @@ -473,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} ще)" @@ -622,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 376b6227d..91f477a70 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -117,7 +117,8 @@ "logout": "Đăng xuất", "link": "Quản lý", "donate": "Quyên góp", - "hosts": "Hosts" + "hosts": "Hosts", + "docs": "Tài liệu" }, "pages": { "login": { @@ -473,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)" @@ -643,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 94739bdbc..6ab1b9adb 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -117,7 +117,8 @@ "logout": "退出登录", "link": "管理", "donate": "捐赠", - "hosts": "主机" + "hosts": "主机", + "docs": "文档" }, "pages": { "login": { @@ -473,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} 项)" @@ -642,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 8beab1ce9..5bd3e2db5 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -117,7 +117,8 @@ "logout": "退出登入", "link": "管理", "donate": "捐贈", - "hosts": "Hosts" + "hosts": "Hosts", + "docs": "文件" }, "pages": { "login": { @@ -473,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} 項)" @@ -622,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/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": 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) 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", ), }, {