Merge branch 'main' into pr-bodylimit-empty-suffix

This commit is contained in:
Sanaei 2026-06-27 14:17:20 +02:00 committed by GitHub
commit 6ae3dab3a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 2204 additions and 174 deletions

View file

@ -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

View file

@ -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 panels \"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": [

View file

@ -463,6 +463,28 @@ export const EXAMPLES: Record<string, unknown> = {
"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": "",

View file

@ -2120,6 +2120,104 @@ export const SCHEMAS: Record<string, unknown> = {
],
"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": {

View file

@ -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;

View file

@ -494,6 +494,28 @@ export const ProbeResultUISchema = z.object({
});
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
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<typeof RealityScanResultSchema>;
export const SettingSchema = z.object({
id: z.number().int(),
key: z.string(),

View file

@ -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;

View file

@ -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 (
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="sidebar-docs"
aria-label={ariaLabel}
title={ariaLabel}
>
<ReadOutlined />
</a>
);
}
function VersionBadge({ version, collapsed }: { version: string; collapsed?: boolean }) {
if (!version) return null;
const label = formatPanelVersion(version);
@ -254,6 +271,7 @@ export default function AppSidebar() {
</div>
{!collapsed && (
<div className="brand-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle"
@ -306,6 +324,7 @@ export default function AppSidebar() {
<span className="drawer-brand">3X-UI</span>
</div>
<div className="drawer-header-actions">
<DocsButton ariaLabel={t('menu.docs') || 'Documentation'} />
<DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
<ThemeCycleButton
id="theme-cycle-drawer"

View file

@ -1,23 +0,0 @@
export interface RealityTarget {
target: string;
sni: string;
}
export const REALITY_TARGETS: readonly RealityTarget[] = [
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
{ target: 'www.amd.com:443', sni: 'www.amd.com' },
{ target: 'www.intel.com:443', sni: 'www.intel.com' },
{ target: 'www.sony.com:443', sni: 'www.sony.com' },
];
export function getRandomRealityTarget(): RealityTarget {
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
const selected = REALITY_TARGETS[randomIndex];
return {
target: selected.target,
sni: selected.sni,
};
}

View file

@ -126,6 +126,14 @@ export const sections: readonly Section[] = [
responseSchema: 'InboundOption',
responseSchemaArray: true,
},
{
method: 'GET',
path: '/panel/api/inbounds/allLinks',
summary:
'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, mtproto) across all inbounds and all of their clients. Links are rendered through the subscription engine, so the configured remark template (name-only display part) is applied per client — the same output the client info/QR pages use. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) contribute nothing. Used by the panels "Export all inbound links" action.',
response:
'{\n "success": true,\n "obj": [\n "vless://uuid@host:443?security=reality&...#Germany-alice",\n "vmess://eyJ2IjoyLC..."\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/inbounds/get/:id',
@ -489,6 +497,27 @@ export const sections: readonly Section[] = [
body: 'server=cloudflare-dns.com',
response: '{\n "success": true,\n "obj": [\n "e8e2d3..."\n ]\n}',
},
{
method: 'POST',
path: '/panel/api/server/scanRealityTarget',
summary: 'Run a live TLS 1.3 probe against a candidate REALITY target and return a feasibility verdict (TLS 1.3 + h2 + X25519 + trusted certificate) plus the certificate SAN DNS names.',
params: [
{ name: 'target', in: 'body (form)', type: 'string', desc: 'Candidate target as host or host:port (default port 443), e.g. www.cloudflare.com:443.' },
],
body: 'target=www.cloudflare.com:443',
responseSchema: 'RealityScanResult',
},
{
method: 'POST',
path: '/panel/api/server/scanRealityTargets',
summary: 'Probe/discover REALITY targets and return each verdict ranked by feasibility then latency. Each comma-separated token may be a domain (validated with SNI), a bare IP, or a CIDR range (discovered without SNI by reading the certificate domain). When empty, a built-in seed list is probed.',
params: [
{ name: 'targets', in: 'body (form)', type: 'string', optional: true, desc: 'Optional comma-separated tokens: domain[:port], IP[:port], or CIDR (e.g. 104.16.0.0/24). When omitted, a built-in seed list is probed.' },
],
body: 'targets=104.16.0.0/24,www.apple.com:443',
responseSchema: 'RealityScanResult',
responseSchemaArray: true,
},
{
method: 'GET',
path: '/panel/api/server/clientIps',

View file

@ -81,7 +81,7 @@ export default function BulkAttachInboundsModal({
{t('pages.clients.attachToInboundsDesc', { count })}
</Typography.Paragraph>
{targetOptions.length === 0 ? (
<Alert type="info" showIcon message={t('pages.clients.attachToInboundsNoTargets')} />
<Alert type="info" showIcon title={t('pages.clients.attachToInboundsNoTargets')} />
) : (
<>
<SelectAllClearButtons

View file

@ -81,7 +81,7 @@ export default function BulkDetachInboundsModal({
{t('pages.clients.detachFromInboundsDesc', { count })}
</Typography.Paragraph>
{targetOptions.length === 0 ? (
<Alert type="info" showIcon message={t('pages.clients.detachFromInboundsNoTargets')} />
<Alert type="info" showIcon title={t('pages.clients.detachFromInboundsNoTargets')} />
) : (
<>
<SelectAllClearButtons

View file

@ -139,7 +139,7 @@ export default function GroupAddClientsModal({
</Typography.Text>
</Space>
{rows.length === 0 ? (
<Alert type="info" showIcon message={t('pages.groups.addToGroupEmpty')} />
<Alert type="info" showIcon title={t('pages.groups.addToGroupEmpty')} />
) : (
<Table<ClientRow>
size="small"

View file

@ -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(

View file

@ -192,7 +192,7 @@ export default function AttachClientsModal({
</Space>
{targetOptions.length === 0 ? (
<Alert type="info" showIcon message={t('pages.inbounds.attachClientsNoTargets')} />
<Alert type="info" showIcon title={t('pages.inbounds.attachClientsNoTargets')} />
) : (
<Select
mode="multiple"

View file

@ -180,7 +180,7 @@ export default function AttachExistingClientsModal({
</Typography.Paragraph>
{noClients ? (
<Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
<Alert type="info" showIcon title={t('pages.inbounds.attachExistingNoClients')} />
) : (
<Spin spinning={loading}>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>

View file

@ -17,6 +17,7 @@ import {
} from 'antd';
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
import type { RealityScanResult } from '@/generated/types';
import {
rawInboundToFormValues,
formValuesToWirePayload,
@ -174,6 +175,8 @@ export default function InboundFormModal({
const [messageApi, messageContextHolder] = message.useMessage();
const [form] = Form.useForm<InboundFormValues>();
const [saving, setSaving] = useState(false);
const [scanning, setScanning] = useState(false);
const [scanResult, setScanResult] = useState<RealityScanResult | null>(null);
const {
fallbacks,
fallbackChildOptions,
@ -241,7 +244,9 @@ export default function InboundFormModal({
clearRealityKeypair,
genMldsa65,
clearMldsa65,
randomizeRealityTarget,
scanRealityTarget,
scanRealityCandidates,
applyRealityScanResult,
randomizeShortIds,
getNewEchCert,
clearEchCert,
@ -250,7 +255,7 @@ export default function InboundFormModal({
setCertFromPanel,
clearCertFiles,
onSecurityChange,
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
} = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null, setScanResult, setScanning });
const toggleSockopt = (on: boolean) => {
@ -347,6 +352,7 @@ export default function InboundFormModal({
: buildAddModeValues();
form.resetFields();
form.setFieldsValue(initial);
setScanResult(null);
const initialTag = (initial.tag ?? '') as string;
autoTagRef.current = isAutoInboundTag(initialTag, {
port: initial.port ?? 0,
@ -890,7 +896,11 @@ export default function InboundFormModal({
{security === 'reality' && (
<RealityForm
saving={saving}
randomizeRealityTarget={randomizeRealityTarget}
scanning={scanning}
scanResult={scanResult}
scanRealityTarget={scanRealityTarget}
scanRealityCandidates={scanRealityCandidates}
applyRealityScanResult={applyRealityScanResult}
randomizeShortIds={randomizeShortIds}
genRealityKeypair={genRealityKeypair}
clearRealityKeypair={clearRealityKeypair}

View file

@ -0,0 +1,174 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Modal, Space, Table, Tag, Tooltip, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { RealityScanResult } from '@/generated/types';
interface RealityTargetScannerModalProps {
open: boolean;
onClose: () => void;
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
onPick: (result: RealityScanResult) => void;
}
export default function RealityTargetScannerModal({
open,
onClose,
scanRealityCandidates,
onPick,
}: RealityTargetScannerModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState('');
const [results, setResults] = useState<RealityScanResult[]>([]);
const scanRef = useRef(scanRealityCandidates);
scanRef.current = scanRealityCandidates;
const runScan = useCallback(async (targets?: string) => {
setLoading(true);
try {
setResults(await scanRef.current(targets));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!open) return;
setResults([]);
runScan();
}, [open, runScan]);
const columns: ColumnsType<RealityScanResult> = [
{
title: t('pages.inbounds.form.target'),
dataIndex: 'target',
key: 'target',
width: 200,
render: (target: string, row) => (
<Tooltip title={row.ip ? `${target}${row.ip}` : target}>
<div style={{ lineHeight: 1.25 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{target}</div>
{row.ip ? <div style={{ color: '#999', fontSize: 12 }}>{row.ip}</div> : null}
</div>
</Tooltip>
),
},
{
title: t('pages.inbounds.form.scanStatus'),
dataIndex: 'feasible',
key: 'feasible',
width: 95,
render: (feasible: boolean, row) =>
feasible ? (
<Tag color="success">{t('pages.inbounds.form.scanFeasible')}</Tag>
) : (
<Tooltip title={row.reason}>
<Tag color="warning">{t('pages.inbounds.form.scanNotFeasible')}</Tag>
</Tooltip>
),
},
{
title: 'TLS',
dataIndex: 'tlsVersion',
key: 'tlsVersion',
width: 60,
render: (v: string) => v || '—',
},
{
title: 'ALPN',
dataIndex: 'alpn',
key: 'alpn',
width: 75,
render: (v: string) => v || '—',
},
{
title: t('pages.inbounds.form.scanCurve'),
dataIndex: 'curveID',
key: 'curveID',
width: 130,
render: (v: string) => v || '—',
},
{
title: t('pages.inbounds.form.scanCert'),
dataIndex: 'certSubject',
key: 'certSubject',
width: 160,
ellipsis: true,
render: (_: string, row) =>
row.certValid ? (
<Tooltip title={`${row.certSubject} (${row.certIssuer})`}>
<span>{row.certSubject || '—'}</span>
</Tooltip>
) : (
<Tag>{t('pages.inbounds.form.scanCertInvalid')}</Tag>
),
},
{
title: t('pages.inbounds.form.scanLatency'),
dataIndex: 'latencyMs',
key: 'latencyMs',
width: 85,
render: (v: number) => (v > 0 ? `${v} ms` : '—'),
},
{
title: '',
key: 'action',
width: 64,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={() => {
onPick(row);
onClose();
}}
>
{t('pages.inbounds.form.scanUse')}
</Button>
),
},
];
return (
<Modal
open={open}
onCancel={onClose}
footer={[
<Button key="rescan" onClick={() => runScan(query.trim() || undefined)} loading={loading}>
{t('pages.inbounds.form.scanRescan')}
</Button>,
<Button key="close" type="primary" onClick={onClose}>
{t('close')}
</Button>,
]}
title={t('pages.inbounds.form.scanModalTitle')}
width={960}
>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
{t('pages.inbounds.form.scanModalDesc')}
</Typography.Paragraph>
<Input.Search
allowClear
enterButton={t('pages.inbounds.form.scan')}
loading={loading}
value={query}
onChange={(e) => setQuery(e.target.value)}
onSearch={() => runScan(query.trim() || undefined)}
placeholder={t('pages.inbounds.form.scanDiscoverPlaceholder')}
/>
<Table<RealityScanResult>
size="small"
rowKey="target"
loading={loading}
columns={columns}
dataSource={results}
pagination={false}
scroll={{ y: 360 }}
/>
</Space>
</Modal>
);
}

View file

@ -1,13 +1,20 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Collapse, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { Alert, Button, Collapse, Descriptions, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { RadarChartOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
import type { RealityScanResult } from '@/generated/types';
import RealityTargetScannerModal from './RealityTargetScannerModal';
interface RealityFormProps {
saving: boolean;
randomizeRealityTarget: () => void;
scanning: boolean;
scanResult: RealityScanResult | null;
scanRealityTarget: () => void;
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
applyRealityScanResult: (result: RealityScanResult) => void;
randomizeShortIds: () => void;
genRealityKeypair: () => void;
clearRealityKeypair: () => void;
@ -17,7 +24,11 @@ interface RealityFormProps {
export default function RealityForm({
saving,
randomizeRealityTarget,
scanning,
scanResult,
scanRealityTarget,
scanRealityCandidates,
applyRealityScanResult,
randomizeShortIds,
genRealityKeypair,
clearRealityKeypair,
@ -25,6 +36,7 @@ export default function RealityForm({
clearMldsa65,
}: RealityFormProps) {
const { t } = useTranslation();
const [scannerOpen, setScannerOpen] = useState(false);
return (
<>
<Form.Item
@ -49,7 +61,7 @@ export default function RealityForm({
label={t('pages.inbounds.form.target')}
tooltip={t('pages.inbounds.form.realityTargetHint')}
>
<Space.Compact block>
<Space.Compact block style={{ display: 'flex' }}>
<Form.Item
name={['streamSettings', 'realitySettings', 'target']}
noStyle
@ -62,21 +74,48 @@ export default function RealityForm({
},
]}
>
<Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
<Input style={{ flex: 1 }} placeholder="example.com:443" />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
<Button icon={<RadarChartOutlined />} loading={scanning} onClick={scanRealityTarget}>
{t('pages.inbounds.form.scan')}
</Button>
<Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
{t('pages.inbounds.form.findTargets')}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item label="SNI">
<Space.Compact block style={{ display: 'flex' }}>
<Form.Item
name={['streamSettings', 'realitySettings', 'serverNames']}
noStyle
>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
</Space.Compact>
{scanResult && (
<Form.Item label=" " colon={false}>
<Alert
type={scanResult.feasible ? 'success' : 'warning'}
showIcon
title={
scanResult.feasible
? t('pages.inbounds.form.scanFeasible')
: scanResult.reason || t('pages.inbounds.form.scanNotFeasible')
}
description={
<Descriptions size="small" column={1}>
<Descriptions.Item label="TLS">{scanResult.tlsVersion || '—'}</Descriptions.Item>
<Descriptions.Item label="ALPN">{scanResult.alpn || '—'}</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanCurve')}>
{scanResult.curveID || '—'}
</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanCert')}>
{scanResult.certValid
? `${scanResult.certSubject} (${scanResult.certIssuer})`
: t('pages.inbounds.form.scanCertInvalid')}
</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
{scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
</Descriptions.Item>
</Descriptions>
}
/>
</Form.Item>
)}
<Form.Item label="SNI" name={['streamSettings', 'realitySettings', 'serverNames']}>
<Select mode="tags" tokenSeparators={[',']} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'maxTimediff']}
@ -201,6 +240,12 @@ export default function RealityForm({
},
]}
/>
<RealityTargetScannerModal
open={scannerOpen}
onClose={() => setScannerOpen(false)}
scanRealityCandidates={scanRealityCandidates}
onPick={applyRealityScanResult}
/>
</>
);
}

View file

@ -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<InboundFormValues>;
@ -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<SetStateAction<RealityScanResult | null>>;
setScanning: Dispatch<SetStateAction<boolean>>;
}
// 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<RealityScanResult>(
'/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<RealityScanResult[]> => {
const msg = await HttpUtil.post<RealityScanResult[]>(
'/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<string, unknown>) ?? {};
const cleaned: Record<string, unknown> = { ...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<string, unknown>;
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,

View file

@ -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<PanelUpdateInfo>({
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() {
<PanelUpdateModal
open={panelUpdateOpen}
info={panelUpdateInfo}
isDevBuild={isDevBuild}
devChannelEnable={devChannelEnable}
onChannelChange={handleChannelChange}
onClose={() => setPanelUpdateOpen(false)}

View file

@ -25,7 +25,6 @@ interface BusyEvent {
interface PanelUpdateModalProps {
open: boolean;
info: PanelUpdateInfo;
isDevBuild?: boolean;
devChannelEnable?: boolean;
onChannelChange?: (dev: boolean) => void | Promise<void>;
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 && (
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.devChannel')}</span>
<Switch
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
</div>
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.devChannel')}</span>
<Switch
checked={!!devChannelEnable}
loading={channelBusy}
onChange={handleChannel}
/>
</div>
)}
</div>
{devChannelEnable && (
<Alert

View file

@ -41,7 +41,7 @@ function UpdateChannelChoice({ onChange }: { onChange: (dev: boolean) => void })
type="info"
showIcon
style={{ marginTop: 8 }}
message={t('pages.index.devChannelWarning')}
title={t('pages.index.devChannelWarning')}
/>
)}
</div>

View file

@ -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<AllSetting>) => 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() {

View file

@ -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;
}
}

View file

@ -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[] {

View file

@ -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 });

View file

@ -133,7 +133,7 @@ export function useRoutingColumns({
key: 'network',
render: (_v, record) => (
<div className="criterion-flow">
{record.network && <CriterionRow label="L4" value={record.network} title={`L4: ${record.network}`} />}
{record.network && <CriterionRow label="L4" value={record.network.toUpperCase()} title={`L4: ${record.network.toUpperCase()}`} />}
{record.protocol && <CriterionRow label="Protocol" value={record.protocol} title={`Protocol: ${record.protocol}`} />}
{record.attrs && <CriterionRow label="Attrs" value={record.attrs} title={`Attrs: ${record.attrs}`} />}
{!record.network && !record.protocol && !record.attrs && <span className="criterion-empty"></span>}

View file

@ -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(<SecurityTab allSetting={{} as AllSetting} updateSetting={vi.fn()} />);
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);
});
});

View file

@ -10,7 +10,8 @@ function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> =
// 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);

View file

@ -98,7 +98,11 @@ describe('inbound security forms', () => {
renderInForm(() => (
<RealityForm
saving={false}
randomizeRealityTarget={noop}
scanning={false}
scanResult={null}
scanRealityTarget={noop}
scanRealityCandidates={async () => []}
applyRealityScanResult={noop}
randomizeShortIds={noop}
genRealityKeypair={noop}
clearRealityKeypair={noop}

View file

@ -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)
}
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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;

View file

@ -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)
}
}

View file

@ -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

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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.

View file

@ -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()

View file

@ -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)
}
})
}
}

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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")

View file

@ -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),
}
}

View file

@ -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)
}
})
}
}

View file

@ -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
}

View file

@ -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
})
}

View file

@ -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)
}
}
}

View file

@ -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

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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":

View file

@ -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)

View file

@ -78,6 +78,7 @@ func run(root, outDir string) error {
StructAllow: setOf(
"InboundOption",
"ProbeResultUI",
"RealityScanResult",
),
},
{