mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-10 14:46:08 +00:00
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:
@@ -317,7 +317,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
""
|
||||
],
|
||||
"updatedAt": 0,
|
||||
"verifyPeerCertByName": false,
|
||||
"verifyPeerCertByName": "",
|
||||
"vlessRoute": ""
|
||||
},
|
||||
"Inbound": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -324,7 +324,7 @@ export interface Host {
|
||||
sortOrder: number;
|
||||
tags: string[];
|
||||
updatedAt: number;
|
||||
verifyPeerCertByName: boolean;
|
||||
verifyPeerCertByName: string;
|
||||
vlessRoute: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user