feat(tls,reality): port xray TLS/REALITY fields, cert-hash helpers, fallback UX

TLS: add verifyPeerCertByName (vcn) to inbound settings + emit in both share-link generators (frontend + Go sub) and outbound parser; the allowInsecure replacement xray removed after 2026-06-01. Add server-side curvePreferences, masterKeyLog, echSockopt (passthrough + form) at tlsSettings top-level so they survive the panel-only settings strip.

REALITY: add limitFallbackUpload/Download (afterBytes/bytesPerSec/burstBytesPerSec) with per-field tooltips, plus masterKeyLog. Verified field names/semantics against pinned xray v1.260327.1 (bytesPerSec=0 disables).

Hosts: fix verify_peer_cert_by_name column bool->string (xray expects comma-separated names) with an idempotent, history-gate-free migration (SQLite typeof blank; Postgres ALTER once); emit vcn for hosts/external proxies.

Server: add getCertHash (local cert DER SHA-256) and getRemoteCertHash (xray tls ping) endpoints + api-docs; wire pinned-cert field buttons. Drop the meaningless random-hash button.

Xray UI: metrics endpoint (listen/tag) config in Basics; import/export for routing rules and outbounds.

Fallbacks card: compact empty state, header-aligned actions, responsive labeled grid rows.

i18n: add all new keys to every locale; drop unused generateRandomPin.
This commit is contained in:
MHSanaei
2026-06-21 15:51:50 +02:00
parent 315ecc2588
commit 7c8889466b
48 changed files with 1316 additions and 173 deletions
+1 -1
View File
@@ -317,7 +317,7 @@ export const EXAMPLES: Record<string, unknown> = {
""
],
"updatedAt": 0,
"verifyPeerCertByName": false,
"verifyPeerCertByName": "",
"vlessRoute": ""
},
"Inbound": {
+1 -1
View File
@@ -1448,7 +1448,7 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "integer"
},
"verifyPeerCertByName": {
"type": "boolean"
"type": "string"
},
"vlessRoute": {
"description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
+1 -1
View File
@@ -324,7 +324,7 @@ export interface Host {
sortOrder: number;
tags: string[];
updatedAt: number;
verifyPeerCertByName: boolean;
verifyPeerCertByName: string;
vlessRoute: string;
}
+1 -1
View File
@@ -347,7 +347,7 @@ export const HostSchema = z.object({
sortOrder: z.number().int(),
tags: z.array(z.string()),
updatedAt: z.number().int(),
verifyPeerCertByName: z.boolean(),
verifyPeerCertByName: z.string(),
vlessRoute: z.string(),
});
export type Host = z.infer<typeof HostSchema>;
+2
View File
@@ -13,6 +13,7 @@ export type HostLinkInput = Pick<
| 'alpn'
| 'fingerprint'
| 'pinnedPeerCertSha256'
| 'verifyPeerCertByName'
| 'echConfigList'
| 'overrideSniFromAddress'
| 'keepSniBlank'
@@ -45,6 +46,7 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
alpn: host.alpn && host.alpn.length > 0 ? host.alpn : undefined,
pinnedPeerCertSha256:
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
verifyPeerCertByName: host.verifyPeerCertByName || undefined,
echConfigList: host.echConfigList || undefined,
};
}
+18
View File
@@ -157,6 +157,9 @@ function applyExternalProxyTLSObj(
if (alpn.length > 0) obj.alpn = alpn;
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
if (pins.length > 0) obj.pcs = pins;
if (externalProxy.verifyPeerCertByName && externalProxy.verifyPeerCertByName.length > 0) {
obj.vcn = externalProxy.verifyPeerCertByName;
}
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) obj.ech = externalProxy.echConfigList;
}
@@ -254,6 +257,9 @@ export function genVmessLink(input: GenVmessLinkInput): string {
if (tlsSettings.settings.fingerprint.length > 0) obj.fp = tlsSettings.settings.fingerprint;
if (tlsSettings.alpn.length > 0) obj.alpn = tlsSettings.alpn.join(',');
if (tlsSettings.settings.echConfigList.length > 0) obj.ech = tlsSettings.settings.echConfigList;
if (tlsSettings.settings.verifyPeerCertByName.length > 0) {
obj.vcn = tlsSettings.settings.verifyPeerCertByName;
}
if (tlsSettings.settings.pinnedPeerCertSha256.length > 0) {
obj.pcs = tlsSettings.settings.pinnedPeerCertSha256.join(',');
}
@@ -301,6 +307,9 @@ function applyExternalProxyTLSParams(
if (alpn.length > 0) params.set('alpn', alpn);
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
if (pins.length > 0) params.set('pcs', pins);
if (externalProxy.verifyPeerCertByName && externalProxy.verifyPeerCertByName.length > 0) {
params.set('vcn', externalProxy.verifyPeerCertByName);
}
if (externalProxy.echConfigList && externalProxy.echConfigList.length > 0) params.set('ech', externalProxy.echConfigList);
}
@@ -384,6 +393,9 @@ export function genVlessLink(input: GenVlessLinkInput): string {
params.set('alpn', tls.alpn.join(','));
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
if (tls.settings.verifyPeerCertByName.length > 0) {
params.set('vcn', tls.settings.verifyPeerCertByName);
}
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
}
@@ -476,6 +488,9 @@ function writeTlsParams(stream: NonNullable<Inbound['streamSettings']>, params:
params.set('alpn', tls.alpn.join(','));
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
if (tls.settings.verifyPeerCertByName.length > 0) {
params.set('vcn', tls.settings.verifyPeerCertByName);
}
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pcs', tls.settings.pinnedPeerCertSha256.join(','));
}
@@ -701,6 +716,9 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
if (tls.alpn.length > 0) params.set('alpn', tls.alpn.join(','));
if (tls.settings.echConfigList.length > 0) params.set('ech', tls.settings.echConfigList);
if (tls.serverName.length > 0) params.set('sni', tls.serverName);
if (tls.settings.verifyPeerCertByName.length > 0) {
params.set('vcn', tls.settings.verifyPeerCertByName);
}
if (tls.settings.pinnedPeerCertSha256.length > 0) {
params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.map(hysteriaPinHex).join(','));
}
@@ -213,6 +213,7 @@ function applySecurityParams(stream: Raw, params: URLSearchParams): void {
const alpn = params.get('alpn');
if (alpn) tls.alpn = alpn.split(',');
tls.echConfigList = params.get('ech') ?? '';
tls.verifyPeerCertByName = params.get('vcn') ?? '';
tls.pinnedPeerCertSha256 = params.get('pcs') ?? '';
} else if (stream.security === 'reality') {
const reality = stream.realitySettings as Raw;
@@ -434,7 +435,7 @@ export function parseHysteria2Link(link: string): Raw | null {
alpn: alpn ? alpn.split(',') : ['h3'],
fingerprint: params.get('fp') ?? '',
echConfigList: params.get('ech') ?? '',
verifyPeerCertByName: '',
verifyPeerCertByName: params.get('vcn') ?? '',
pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
},
};
@@ -129,6 +129,20 @@ function normalizeTlsForWire(raw: Record<string, unknown>): Record<string, unkno
const out: Record<string, unknown> = { ...raw };
if (out.fingerprint === '') delete out.fingerprint;
// Empty server-side tuning fields mean "use xray-core's default" — never emit them.
if (Array.isArray(out.curvePreferences) && out.curvePreferences.length === 0) {
delete out.curvePreferences;
}
if (out.masterKeyLog === '' || out.masterKeyLog == null) delete out.masterKeyLog;
if (isRecord(out.echSockopt)) {
const echSock = normalizeSockoptForWire(out.echSockopt);
if (echSock) {
out.echSockopt = echSock;
} else {
delete out.echSockopt;
}
}
const settings = out.settings;
if (isRecord(settings)) {
const settingsOut: Record<string, unknown> = { ...settings };
+21
View File
@@ -453,6 +453,27 @@ export const sections: readonly Section[] = [
body: 'sni=example.com',
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}',
},
{
method: 'POST',
path: '/panel/api/server/getCertHash',
summary: 'Compute the hex SHA-256 of a certificate (DER) for pinning (pinnedPeerCertSha256). Provide either a server file path or inline PEM/DER content.',
params: [
{ name: 'certFile', in: 'body (form)', type: 'string', desc: 'Path to a certificate file on the server. Takes precedence over certContent.' },
{ name: 'certContent', in: 'body (form)', type: 'string', desc: 'Inline PEM (or DER) certificate content, used when certFile is empty.' },
],
body: 'certFile=/root/cert.crt',
response: '{\n "success": true,\n "obj": [\n "e8e2d3..."\n ]\n}',
},
{
method: 'POST',
path: '/panel/api/server/getRemoteCertHash',
summary: 'Run `xray tls ping` against a remote server and return its live leaf-certificate SHA-256 hash(es) for pinning (pinnedPeerCertSha256).',
params: [
{ name: 'server', in: 'body (form)', type: 'string', desc: 'Remote server as domain or domain:port (default port 443), e.g. cloudflare-dns.com.' },
],
body: 'server=cloudflare-dns.com',
response: '{\n "success": true,\n "obj": [\n "e8e2d3..."\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/server/clientIps',
+3 -3
View File
@@ -56,7 +56,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
keepSniBlank: host?.keepSniBlank ?? false,
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
verifyPeerCertByName: host?.verifyPeerCertByName ?? false,
verifyPeerCertByName: (host?.verifyPeerCertByName as string | undefined) ?? '',
allowInsecure: host?.allowInsecure ?? false,
echConfigList: host?.echConfigList ?? '',
muxParams: asString(host?.muxParams),
@@ -224,8 +224,8 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
<Form.Item name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
<Select mode="tags" allowClear tokenSeparators={[',']} />
</Form.Item>
<Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} valuePropName="checked">
<Switch />
<Form.Item name="verifyPeerCertByName" label={t('pages.hosts.fields.verifyPeerCertByName')} tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}>
<Input placeholder="example.com" />
</Form.Item>
<Form.Item name="allowInsecure" label={t('pages.hosts.fields.allowInsecure')} tooltip={t('pages.hosts.hints.allowInsecure')} valuePropName="checked">
<Switch />
@@ -1,8 +1,7 @@
import { useTranslation } from 'react-i18next';
import { Button, Card, Empty, Input, InputNumber, Select, Space } from 'antd';
import { Button, Card, Col, Empty, Input, InputNumber, Row, Select, Space } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { InputAddon } from '@/components/ui';
import type { FallbackRow } from '@/schemas/forms/inbound-form';
interface FallbacksCardProps {
@@ -25,100 +24,120 @@ export default function FallbacksCard({
addAllFallbacks,
}: FallbacksCardProps) {
const { t } = useTranslation();
const addButtons = (
<Space size={8} wrap>
<Button type="primary" ghost size="small" icon={<PlusOutlined />} onClick={addFallback}>
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
</Button>
<Button
size="small"
onClick={addAllFallbacks}
disabled={fallbackChildOptions.length === 0 || fallbacks.length >= fallbackChildOptions.length}
title={t('pages.inbounds.form.addAllFallbackTooltip')}
>
{t('pages.inbounds.form.addAll')}
</Button>
</Space>
);
return (
<Card size="small" className="mt-12" title={t('pages.inbounds.fallbacks.title') || 'Fallbacks'}>
{fallbacks.length === 0 && (
<Card
size="small"
className="mt-12"
title={t('pages.inbounds.fallbacks.title') || 'Fallbacks'}
extra={addButtons}
>
{fallbacks.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
styles={{ image: { height: 36 } }}
description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'}
styles={{ image: { height: 40 } }}
style={{ margin: '8px 0 12px' }}
style={{ margin: '4px 0 12px' }}
/>
) : (
fallbacks.map((record, idx) => (
<Card
key={record.rowKey}
type="inner"
size="small"
style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }}
>
<Space.Compact block style={{ marginBottom: 8 }}>
<Select
value={record.childId}
options={fallbackChildOptions}
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
allowClear
showSearch={{
filterOption: (input, option) =>
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
}}
style={{ width: '100%' }}
onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
/>
<Button
disabled={idx === 0}
onClick={() => moveFallback(idx, -1)}
title={t('pages.inbounds.form.moveUp')}
icon={<ArrowUpOutlined />}
/>
<Button
disabled={idx === fallbacks.length - 1}
onClick={() => moveFallback(idx, 1)}
title={t('pages.inbounds.form.moveDown')}
icon={<ArrowDownOutlined />}
/>
<Button danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
</Space.Compact>
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}>
<Input
addonBefore="SNI"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.name}
onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
/>
</Col>
<Col xs={24} sm={12}>
<Input
addonBefore="ALPN"
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.alpn}
onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
/>
</Col>
<Col xs={24} sm={12}>
<Input
addonBefore="Path"
placeholder="/"
value={record.path}
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
/>
</Col>
<Col xs={24} sm={12}>
<Input
addonBefore="Dest"
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
value={record.dest}
onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
/>
</Col>
<Col xs={24} sm={12}>
<InputNumber
addonBefore="xver"
min={0}
max={2}
style={{ width: '100%' }}
value={record.xver}
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })}
/>
</Col>
</Row>
</Card>
))
)}
{fallbacks.map((record, idx) => (
<div
key={record.rowKey}
style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}
>
<Space.Compact block style={{ marginBottom: 6 }}>
<Select
value={record.childId}
options={fallbackChildOptions}
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
allowClear
showSearch={{
filterOption: (input, option) =>
((option?.label as string) || '').toLowerCase().includes(input.toLowerCase()),
}}
style={{ width: '100%' }}
onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
/>
<Button
disabled={idx === 0}
onClick={() => moveFallback(idx, -1)}
title={t('pages.inbounds.form.moveUp')}
>
<ArrowUpOutlined />
</Button>
<Button
disabled={idx === fallbacks.length - 1}
onClick={() => moveFallback(idx, 1)}
title={t('pages.inbounds.form.moveDown')}
>
<ArrowDownOutlined />
</Button>
<Button danger onClick={() => removeFallback(idx)}>
<DeleteOutlined />
</Button>
</Space.Compact>
<Space.Compact block>
<InputAddon>SNI</InputAddon>
<Input
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.name}
onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
/>
<InputAddon>ALPN</InputAddon>
<Input
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.alpn}
onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
/>
<InputAddon>Path</InputAddon>
<Input
placeholder="/"
value={record.path}
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
/>
<InputAddon>Dest</InputAddon>
<Input
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
value={record.dest}
onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
/>
<InputAddon>xver</InputAddon>
<InputNumber
min={0}
max={2}
value={record.xver}
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })}
/>
</Space.Compact>
</div>
))}
<Space>
<Button size="small" onClick={addFallback}>
<PlusOutlined /> {t('pages.inbounds.fallbacks.add') || 'Add fallback'}
</Button>
<Button
size="small"
onClick={addAllFallbacks}
disabled={fallbackChildOptions.length === 0
|| fallbacks.length >= fallbackChildOptions.length}
title={t('pages.inbounds.form.addAllFallbackTooltip')}
>
{t('pages.inbounds.form.addAll')}
</Button>
</Space>
</Card>
);
}
@@ -244,7 +244,8 @@ export default function InboundFormModal({
randomizeShortIds,
getNewEchCert,
clearEchCert,
generateRandomPinHash,
pinFromCert,
pinFromRemote,
setCertFromPanel,
clearCertFiles,
onSecurityChange,
@@ -854,7 +855,8 @@ export default function InboundFormModal({
saving={saving}
setCertFromPanel={setCertFromPanel}
clearCertFiles={clearCertFiles}
generateRandomPinHash={generateRandomPinHash}
pinFromCert={pinFromCert}
pinFromRemote={pinFromRemote}
getNewEchCert={getNewEchCert}
clearEchCert={clearEchCert}
/>
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { Button, Collapse, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
@@ -153,6 +153,54 @@ export default function RealityForm({
<Button danger onClick={clearMldsa65}>{t('clear')}</Button>
</Space>
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', 'masterKeyLog']}
label={t('pages.inbounds.form.masterKeyLog')}
tooltip={t('pages.inbounds.form.masterKeyLogTip')}
>
<Input placeholder="/path/to/sslkeylog.txt" />
</Form.Item>
<Collapse
style={{ marginBottom: 14 }}
items={[
{
key: 'limitFallback',
label: t('pages.inbounds.form.limitFallback'),
children: (
<>
{(['limitFallbackUpload', 'limitFallbackDownload'] as const).map((dir) => (
<div key={dir}>
<Divider style={{ margin: '0 0 14px 0' }}>
{t(`pages.inbounds.form.${dir}`)}
</Divider>
<Form.Item
name={['streamSettings', 'realitySettings', dir, 'afterBytes']}
label={t('pages.inbounds.form.afterBytes')}
tooltip={t('pages.inbounds.form.afterBytesTip')}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', dir, 'bytesPerSec']}
label={t('pages.inbounds.form.bytesPerSec')}
tooltip={t('pages.inbounds.form.bytesPerSecTip')}
>
<InputNumber min={0} />
</Form.Item>
<Form.Item
name={['streamSettings', 'realitySettings', dir, 'burstBytesPerSec']}
label={t('pages.inbounds.form.burstBytesPerSec')}
tooltip={t('pages.inbounds.form.burstBytesPerSecTip')}
>
<InputNumber min={0} />
</Form.Item>
</div>
))}
</>
),
},
]}
/>
</>
);
}
+112 -15
View File
@@ -1,14 +1,16 @@
import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { CloudDownloadOutlined, FileProtectOutlined, MinusOutlined, PlusOutlined } from '@ant-design/icons';
import {
ALPN_OPTION,
DOMAIN_STRATEGY_OPTION,
TLS_CIPHER_OPTION,
TLS_VERSION_OPTION,
USAGE_OPTION,
UTLS_FINGERPRINT,
} from '@/schemas/primitives';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
const { TextArea } = Input;
@@ -16,7 +18,8 @@ interface TlsFormProps {
saving: boolean;
setCertFromPanel: (certName: number) => void;
clearCertFiles: (certName: number) => void;
generateRandomPinHash: () => void;
pinFromCert: () => void;
pinFromRemote: () => void;
getNewEchCert: () => void;
clearEchCert: () => void;
}
@@ -25,7 +28,8 @@ export default function TlsForm({
saving,
setCertFromPanel,
clearCertFiles,
generateRandomPinHash,
pinFromCert,
pinFromRemote,
getNewEchCert,
clearEchCert,
}: TlsFormProps) {
@@ -78,6 +82,21 @@ export default function TlsForm({
options={Object.values(ALPN_OPTION).map((a) => ({ value: a, label: a }))}
/>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'curvePreferences']}
label={t('pages.inbounds.form.curvePreferences')}
tooltip={t('pages.inbounds.form.curvePreferencesTip')}
>
<Select
mode="tags"
tokenSeparators={[',', ' ']}
style={{ width: '100%' }}
options={['X25519MLKEM768', 'X25519', 'P-256', 'P-384', 'P-521'].map((c) => ({
value: c,
label: c,
}))}
/>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'rejectUnknownSni']}
label={t('pages.inbounds.form.rejectUnknownSni')}
@@ -270,7 +289,71 @@ export default function TlsForm({
</>
)}
</Form.List>
<Form.Item
name={['streamSettings', 'tlsSettings', 'masterKeyLog']}
label={t('pages.inbounds.form.masterKeyLog')}
tooltip={t('pages.inbounds.form.masterKeyLogTip')}
>
<Input placeholder="/path/to/sslkeylog.txt" />
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prev, curr) =>
!!(prev.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
!== !!(curr.streamSettings as { tlsSettings?: { echSockopt?: unknown } } | undefined)?.tlsSettings?.echSockopt
}
>
{({ getFieldValue, setFieldValue }) => {
const on = !!getFieldValue(['streamSettings', 'tlsSettings', 'echSockopt']);
return (
<>
<Form.Item label={t('pages.inbounds.form.echSockopt')} tooltip={t('pages.inbounds.form.echSockoptTip')}>
<Switch
checked={on}
onChange={(v) =>
setFieldValue(
['streamSettings', 'tlsSettings', 'echSockopt'],
v ? SockoptStreamSettingsSchema.parse({}) : undefined,
)
}
/>
</Form.Item>
{on && (
<>
<Form.Item
name={['streamSettings', 'tlsSettings', 'echSockopt', 'dialerProxy']}
label={t('pages.inbounds.form.dialerProxy')}
>
<Input />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'echSockopt', 'domainStrategy']}
label={t('pages.xray.wireguard.domainStrategy')}
>
<Select
options={Object.values(DOMAIN_STRATEGY_OPTION).map((v) => ({ value: v, label: v }))}
/>
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpFastOpen']}
label={t('pages.inbounds.form.tcpFastOpen')}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
name={['streamSettings', 'tlsSettings', 'echSockopt', 'tcpMptcp']}
label={t('pages.inbounds.form.multipathTcp')}
valuePropName="checked"
>
<Switch />
</Form.Item>
</>
)}
</>
);
}}
</Form.Item>
<Form.Item name={['streamSettings', 'tlsSettings', 'echServerKeys']} label={t('pages.inbounds.form.echKey')}>
<Input />
</Form.Item>
@@ -280,6 +363,14 @@ export default function TlsForm({
>
<Input />
</Form.Item>
<Form.Item label=" ">
<Space>
<Button type="primary" loading={saving} onClick={getNewEchCert}>
{t('pages.inbounds.form.getNewEchCert')}
</Button>
<Button danger onClick={clearEchCert}>{t('clear')}</Button>
</Space>
</Form.Item>
<Form.Item
label={t('pages.inbounds.form.pinnedPeerCertSha256')}
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
@@ -293,23 +384,29 @@ export default function TlsForm({
mode="tags"
tokenSeparators={[',', ' ']}
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
style={{ width: 'calc(100% - 32px)' }}
style={{ width: 'calc(100% - 64px)' }}
/>
</Form.Item>
<Button
icon={<ReloadOutlined />}
onClick={generateRandomPinHash}
title={t('pages.inbounds.form.generateRandomPin')}
icon={<FileProtectOutlined />}
onClick={pinFromCert}
loading={saving}
title={t('pages.inbounds.form.pinFromCert')}
/>
<Button
icon={<CloudDownloadOutlined />}
onClick={pinFromRemote}
loading={saving}
title={t('pages.inbounds.form.pinFromRemote')}
/>
</Space.Compact>
</Form.Item>
<Form.Item label=" ">
<Space>
<Button type="primary" loading={saving} onClick={getNewEchCert}>
{t('pages.inbounds.form.getNewEchCert')}
</Button>
<Button danger onClick={clearEchCert}>{t('clear')}</Button>
</Space>
<Form.Item
name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']}
label={t('pages.inbounds.form.verifyPeerCertByName')}
tooltip={t('pages.inbounds.form.verifyPeerCertByNameTip')}
>
<Input placeholder="example.com" />
</Form.Item>
</>
);
@@ -100,17 +100,66 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'echConfigList'], '');
};
const generateRandomPinHash = () => {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
const current = (form.getFieldValue(
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
) as string[] | undefined) ?? [];
form.setFieldValue(
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
[...current, hash],
);
// Fill the pinned-cert field from the inbound's own certificate: read the
// first configured cert (file path or inline content) and ask the server for
// its hex SHA-256, then merge the hash(es) into pinnedPeerCertSha256.
const pinFromCert = async () => {
const certs = (form.getFieldValue(['streamSettings', 'tlsSettings', 'certificates']) ?? []) as Array<{
certificateFile?: string;
certificate?: string[];
}>;
const first = certs[0];
const certFile = first?.certificateFile?.trim() ?? '';
const certContent = Array.isArray(first?.certificate) ? first.certificate.join('\n').trim() : '';
if (!certFile && !certContent) {
messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
return;
}
setSaving(true);
try {
const msg = await HttpUtil.post('/panel/api/server/getCertHash', { certFile, certContent });
if (!msg?.success) {
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
return;
}
const hashes = (msg.obj as string[] | undefined) ?? [];
if (hashes.length === 0) return;
const current = (form.getFieldValue(
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
) as string[] | undefined) ?? [];
const merged = Array.from(new Set([...current, ...hashes]));
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
} finally {
setSaving(false);
}
};
// Fill the pinned-cert field by pinging the configured SNI: fetches the live
// remote certificate hash via `xray tls ping`. Useful when the panel doesn't
// hold the cert file (a CDN front / external endpoint).
const pinFromRemote = async () => {
const server = ((form.getFieldValue(['streamSettings', 'tlsSettings', 'serverName']) as string | undefined) ?? '').trim();
if (!server) {
messageApi.warning(t('pages.inbounds.form.pinFromRemoteNoSni'));
return;
}
setSaving(true);
try {
const msg = await HttpUtil.post('/panel/api/server/getRemoteCertHash', { server });
if (!msg?.success) {
messageApi.warning(msg?.msg || t('pages.inbounds.form.pinFromRemoteFailed'));
return;
}
const hashes = (msg.obj as string[] | undefined) ?? [];
if (hashes.length === 0) return;
const current = (form.getFieldValue(
['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'],
) as string[] | undefined) ?? [];
const merged = Array.from(new Set([...current, ...hashes]));
form.setFieldValue(['streamSettings', 'tlsSettings', 'settings', 'pinnedPeerCertSha256'], merged);
} finally {
setSaving(false);
}
};
const setCertFromPanel = async (certName: number) => {
@@ -194,7 +243,8 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
randomizeShortIds,
getNewEchCert,
clearEchCert,
generateRandomPinHash,
pinFromCert,
pinFromRemote,
setCertFromPanel,
clearCertFiles,
onSecurityChange,
@@ -70,6 +70,28 @@ export default function BasicsTab({
[mutate],
);
const metricsCfg = (templateSettings as { metrics?: { tag?: string; listen?: string } } | null)?.metrics;
const setMetrics = useCallback(
(field: 'tag' | 'listen', value: string) => mutate((tt) => {
const node = tt as { metrics?: { tag?: string; listen?: string }; stats?: Record<string, unknown> };
const m: { tag?: string; listen?: string } = { ...(node.metrics ?? {}) };
if (value.trim() === '') {
delete m[field];
} else {
m[field] = value.trim();
}
if (!m.listen && !m.tag) {
delete node.metrics;
} else {
node.metrics = m;
// xray-core's metrics handler needs a stats object to populate.
if (!node.stats) node.stats = {};
}
}),
[mutate],
);
function confirmResetDefault() {
modal.confirm({
title: t('pages.settings.resetDefaultConfig'),
@@ -272,6 +294,29 @@ export default function BasicsTab({
}
/>
))}
<SettingListItem
title={t('pages.xray.metricsListen')}
description={t('pages.xray.metricsListenDesc')}
paddings="small"
control={
<Input
value={metricsCfg?.listen ?? ''}
onChange={(e) => setMetrics('listen', e.target.value)}
placeholder="127.0.0.1:11111"
/>
}
/>
<SettingListItem
title={t('pages.xray.metricsTag')}
paddings="small"
control={
<Input
value={metricsCfg?.tag ?? ''}
onChange={(e) => setMetrics('tag', e.target.value)}
placeholder="metrics_out"
/>
}
/>
</>
),
},
@@ -34,9 +34,11 @@ import {
CheckCircleOutlined,
WarningOutlined,
ExportOutlined,
ImportOutlined,
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { FileManager, HttpUtil } from '@/utils';
import PromptModal from '@/components/feedback/PromptModal';
import OutboundFormModal from './OutboundFormModal';
import { propagateOutboundTagRename } from '../basics/helpers';
@@ -223,6 +225,35 @@ export default function OutboundsTab({
});
}
const [importOpen, setImportOpen] = useState(false);
function exportOutbounds() {
FileManager.downloadTextFile(JSON.stringify(outbounds, null, 2), 'outbounds.json', {
type: 'application/json',
});
}
function importOutbounds(value: string) {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
messageApi.error(t('pages.xray.importInvalidJson'));
return;
}
const obj = parsed as { outbounds?: unknown };
const list = Array.isArray(parsed) ? parsed : Array.isArray(obj?.outbounds) ? obj.outbounds : null;
if (!list) {
messageApi.error(t('pages.xray.importInvalidJson'));
return;
}
mutate((tt) => {
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
tt.outbounds.push(...(list as never[]));
});
setImportOpen(false);
}
// --- Subscription management (minimal inline UI) ---
async function loadSubs() {
setSubsLoading(true);
@@ -420,6 +451,9 @@ export default function OutboundsTab({
items: [
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
{ type: 'divider' },
{ key: 'import', icon: <ImportOutlined />, label: t('pages.xray.importOutbounds'), onClick: () => setImportOpen(true) },
{ key: 'export', icon: <ExportOutlined />, label: t('pages.xray.exportOutbounds'), disabled: outbounds.length === 0, onClick: exportOutbounds },
],
}}
>
@@ -488,6 +522,15 @@ export default function OutboundsTab({
onClose={() => setModalOpen(false)}
onConfirm={onConfirm}
/>
<PromptModal
open={importOpen}
onClose={() => setImportOpen(false)}
title={t('pages.xray.importOutbounds')}
okText={t('pages.xray.importOutbounds')}
type="textarea"
json
onConfirm={importOutbounds}
/>
{/* Subscription outbounds (read-only, merged at runtime) */}
{Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
+71 -5
View File
@@ -1,9 +1,18 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Modal, Space, Table, Tabs } from 'antd';
import { AimOutlined, ControlOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
import { Button, Modal, Space, Table, Tabs, message } from 'antd';
import {
AimOutlined,
ControlOutlined,
ExportOutlined,
ImportOutlined,
PlusOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { catTabLabel } from '@/pages/settings/catTabLabel';
import { FileManager } from '@/utils';
import PromptModal from '@/components/feedback/PromptModal';
import RoutingBasic from './RoutingBasic';
import RouteTester from './RouteTester';
import RuleFormModal from './RuleFormModal';
@@ -134,6 +143,42 @@ export default function RoutingTab({
return out;
}, [templateSettings?.routing?.balancers]);
const [importOpen, setImportOpen] = useState(false);
function exportRules() {
FileManager.downloadTextFile(JSON.stringify(rules, null, 2), 'routing-rules.json', {
type: 'application/json',
});
}
function importRules(value: string) {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
message.error(t('pages.xray.importInvalidJson'));
return;
}
const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } };
const list = Array.isArray(parsed)
? parsed
: Array.isArray(obj?.rules)
? obj.rules
: Array.isArray(obj?.routing?.rules)
? obj.routing!.rules
: null;
if (!list) {
message.error(t('pages.xray.importInvalidJson'));
return;
}
mutate((tt) => {
if (!tt.routing) tt.routing = { rules: [] };
if (!Array.isArray(tt.routing.rules)) tt.routing.rules = [];
tt.routing.rules.push(...(list as RuleObject[]));
});
setImportOpen(false);
}
function openAdd() {
setEditingRule(null);
setEditingIndex(null);
@@ -284,9 +329,21 @@ export default function RoutingTab({
label: catTabLabel(<UnorderedListOutlined />, t('pages.xray.Routings'), isMobile),
children: (
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Routings')}
</Button>
<Space wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
{t('pages.xray.Routings')}
</Button>
<Button icon={<ImportOutlined />} onClick={() => setImportOpen(true)}>
{t('pages.xray.importRules')}
</Button>
<Button
icon={<ExportOutlined />}
onClick={exportRules}
disabled={rules.length === 0}
>
{t('pages.xray.exportRules')}
</Button>
</Space>
{isMobile ? (
<RuleCardList
@@ -339,6 +396,15 @@ export default function RoutingTab({
onClose={() => setRuleModalOpen(false)}
onConfirm={onRuleConfirm}
/>
<PromptModal
open={importOpen}
onClose={() => setImportOpen(false)}
title={t('pages.xray.importRules')}
okText={t('pages.xray.importRules')}
type="textarea"
json
onConfirm={importRules}
/>
</>
);
}
+10 -2
View File
@@ -46,7 +46,12 @@ export const HostFormSchema = z.object({
overrideSniFromAddress: z.boolean().default(false),
keepSniBlank: z.boolean().default(false),
pinnedPeerCertSha256: z.array(z.string()).default([]),
verifyPeerCertByName: z.boolean().default(false),
// Comma-separated cert names (xray `vcn`). Legacy rows stored a boolean here;
// coerce any stray bool to '' so old data loads cleanly.
verifyPeerCertByName: z.preprocess(
(v) => (typeof v === 'boolean' ? '' : v),
z.string().default(''),
),
allowInsecure: z.boolean().default(false),
echConfigList: z.string().default(''),
@@ -98,7 +103,10 @@ export const HostRecordSchema = z.object({
overrideSniFromAddress: z.boolean().optional(),
keepSniBlank: z.boolean().optional(),
pinnedPeerCertSha256: z.array(z.string()).nullish(),
verifyPeerCertByName: z.boolean().optional(),
verifyPeerCertByName: z.preprocess(
(v) => (typeof v === 'boolean' ? '' : v),
z.string().optional(),
),
allowInsecure: z.boolean().optional(),
echConfigList: z.string().optional(),
muxParams: z.unknown().optional(),
@@ -14,6 +14,17 @@ export const RealityClientSettingsSchema = z.object({
});
export type RealityClientSettings = z.infer<typeof RealityClientSettingsSchema>;
// REALITY fallback rate-limit (xray-core reality.LimitFallback): throttles the
// fallback stream after `afterBytes`, then caps it at `bytesPerSec` with an
// optional `burstBytesPerSec`. Optional so existing inbounds round-trip
// unchanged — the object is only emitted once a user sets a non-zero value.
export const RealityLimitFallbackSchema = z.object({
afterBytes: z.number().int().min(0).default(0),
bytesPerSec: z.number().int().min(0).default(0),
burstBytesPerSec: z.number().int().min(0).default(0),
});
export type RealityLimitFallback = z.infer<typeof RealityLimitFallbackSchema>;
// xray-core accepts both `target` and `dest` as the REALITY destination —
// they are aliases (infra/conf/transport_internet.go: REALITYConfig has
// `json:"target"` and `json:"dest"`). The panel writes `target`, but configs
@@ -52,6 +63,11 @@ export const RealityStreamSettingsSchema = z.preprocess(
maxTimediff: z.number().int().min(0).default(0),
shortIds: z.array(z.string()).default([]),
mldsa65Seed: z.string().default(''),
// Server-side TLS master-key log path (xray-core reality.Config). Optional
// so existing inbounds round-trip unchanged.
masterKeyLog: z.string().optional(),
limitFallbackUpload: RealityLimitFallbackSchema.optional(),
limitFallbackDownload: RealityLimitFallbackSchema.optional(),
settings: RealityClientSettingsSchema.default({
publicKey: '',
fingerprint: 'chrome',
+14 -1
View File
@@ -1,5 +1,7 @@
import { z } from 'zod';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
export const TlsVersionSchema = z.enum(['1.0', '1.1', '1.2', '1.3']);
export type TlsVersion = z.infer<typeof TlsVersionSchema>;
@@ -57,6 +59,11 @@ export const TlsClientSettingsSchema = z.object({
fingerprint: TlsFingerprintSchema.default('chrome'),
echConfigList: z.string().default(''),
pinnedPeerCertSha256: z.array(z.string()).default([]),
// Panel-only client directive (v2rayN `vcn`): verify the server certificate
// against this name instead of the SNI. Comma-separated names. Shipped in
// share links / subscriptions; the modern replacement for `allowInsecure`,
// which xray-core removed after 2026-06-01.
verifyPeerCertByName: z.string().default(''),
});
export type TlsClientSettings = z.infer<typeof TlsClientSettingsSchema>;
@@ -73,6 +80,12 @@ export const TlsStreamSettingsSchema = z.object({
certificates: z.array(TlsCertSchema).default([]),
alpn: z.array(AlpnSchema).default(['h2', 'http/1.1']),
echServerKeys: z.string().default(''),
settings: TlsClientSettingsSchema.default({ fingerprint: 'chrome', echConfigList: '', pinnedPeerCertSha256: [] }),
// Server-side TLS fields (xray-core TLSConfig top-level): survive the
// panel-only `settings` strip and reach the runtime config. Optional so
// existing inbounds round-trip unchanged.
curvePreferences: z.array(z.string()).optional(),
masterKeyLog: z.string().optional(),
echSockopt: SockoptStreamSettingsSchema.optional(),
settings: TlsClientSettingsSchema.default({ fingerprint: 'chrome', echConfigList: '', pinnedPeerCertSha256: [], verifyPeerCertByName: '' }),
});
export type TlsStreamSettings = z.infer<typeof TlsStreamSettingsSchema>;
@@ -23,6 +23,7 @@ export const ExternalProxyEntrySchema = z.object({
),
alpn: z.array(AlpnSchema).optional(),
pinnedPeerCertSha256: z.array(z.string()).optional(),
verifyPeerCertByName: z.string().optional(),
echConfigList: z.string().optional(),
});
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
@@ -16,6 +16,7 @@ exports[`inbound security forms > RealityForm field structure is stable 1`] = `
"Private Key",
"mldsa65 Seed",
"mldsa65 Verify",
"Master Key Log",
]
`;
@@ -26,13 +27,17 @@ exports[`inbound security forms > TlsForm field structure is stable 1`] = `
"Min/Max Version",
"uTLS",
"ALPN",
"Curve Preferences",
"Reject Unknown SNI",
"Disable System Root",
"Session Resumption",
"Digital Certificate",
"Master Key Log",
"ECH Sockopt",
"ECH key",
"ECH config",
"Pinned Peer Cert SHA-256",
"Verify Peer Cert By Name",
]
`;
@@ -74,6 +74,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
"echConfigList": "",
"fingerprint": "chrome",
"pinnedPeerCertSha256": [],
"verifyPeerCertByName": "",
},
},
},
@@ -217,6 +218,7 @@ exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] =
"echConfigList": "",
"fingerprint": "chrome",
"pinnedPeerCertSha256": [],
"verifyPeerCertByName": "",
},
},
"wsSettings": {
@@ -394,6 +396,7 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
"echConfigList": "",
"fingerprint": "chrome",
"pinnedPeerCertSha256": [],
"verifyPeerCertByName": "",
},
},
"wsSettings": {
@@ -488,6 +491,7 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls-pinned byte-stably
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
],
"verifyPeerCertByName": "",
},
},
"wsSettings": {
@@ -583,6 +587,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
"echConfigList": "",
"fingerprint": "chrome",
"pinnedPeerCertSha256": [],
"verifyPeerCertByName": "",
},
},
},
@@ -68,6 +68,7 @@ exports[`SecuritySettingsSchema fixtures > parses tls-cert-file byte-stably 1`]
"echConfigList": "",
"fingerprint": "chrome",
"pinnedPeerCertSha256": [],
"verifyPeerCertByName": "",
},
},
}
+2
View File
@@ -13,6 +13,7 @@ describe('hostToExternalProxyEntry', () => {
alpn: ['h2'] as ('h2' | 'h3' | 'http/1.1')[],
fingerprint: 'chrome' as const,
pinnedPeerCertSha256: ['AAAA'],
verifyPeerCertByName: 'verify.example.com',
echConfigList: 'ECH',
overrideSniFromAddress: false,
keepSniBlank: false,
@@ -28,6 +29,7 @@ describe('hostToExternalProxyEntry', () => {
expect(ep.alpn).toEqual(['h2']);
expect(ep.fingerprint).toBe('chrome');
expect(ep.pinnedPeerCertSha256).toEqual(['AAAA']);
expect(ep.verifyPeerCertByName).toBe('verify.example.com');
expect(ep.echConfigList).toBe('ECH');
});
@@ -85,7 +85,8 @@ describe('inbound security forms', () => {
saving={false}
setCertFromPanel={noop}
clearCertFiles={noop}
generateRandomPinHash={noop}
pinFromCert={noop}
pinFromRemote={noop}
getNewEchCert={noop}
clearEchCert={noop}
/>