mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-15 00:56:08 +00:00
feat(inbounds): per-proxy Pinned Peer Cert SHA-256 + labeled External Proxy form
Redesign the Add Inbound -> Stream External Proxy section into labeled per-entry cards (Force TLS / Host / Port / Remark and, under TLS, SNI / Fingerprint / ALPN) and add a Pinned Peer Cert SHA-256 field with a generate-random-hash button to each entry. The pin flows end to end into share links: pcs for vmess/vless/trojan/ss (stripped when a proxy forces security off) and the hex-normalized pinSHA256 for Hysteria. JSON and Clash subscriptions emit the native pinnedPeerCertSha256 / pin-sha256 via the cloned stream. Adds the forceTls label across all 13 locales plus frontend and Go tests.
This commit is contained in:
@@ -119,6 +119,11 @@ function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function externalProxyPins(value: ExternalProxyEntry['pinnedPeerCertSha256']): string {
|
||||
if (Array.isArray(value)) return value.filter(Boolean).join(',');
|
||||
return '';
|
||||
}
|
||||
|
||||
function applyExternalProxyTLSObj(
|
||||
externalProxy: ExternalProxyEntry | null | undefined,
|
||||
obj: Record<string, unknown>,
|
||||
@@ -130,6 +135,8 @@ function applyExternalProxyTLSObj(
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
|
||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) obj.alpn = alpn;
|
||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||
if (pins.length > 0) obj.pcs = pins;
|
||||
}
|
||||
|
||||
export interface GenVmessLinkInput {
|
||||
@@ -270,6 +277,8 @@ function applyExternalProxyTLSParams(
|
||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
|
||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||
if (alpn.length > 0) params.set('alpn', alpn);
|
||||
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||
if (pins.length > 0) params.set('pcs', pins);
|
||||
}
|
||||
|
||||
export interface GenVlessLinkInput {
|
||||
@@ -576,6 +585,7 @@ export interface GenHysteriaLinkInput {
|
||||
port?: number;
|
||||
remark?: string;
|
||||
clientAuth: string;
|
||||
externalProxy?: ExternalProxyEntry | null;
|
||||
}
|
||||
|
||||
// Hysteria2's pinSHA256 must be a 64-char lowercase hex string — Xray-core
|
||||
@@ -616,6 +626,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
||||
port = inbound.port,
|
||||
remark = '',
|
||||
clientAuth,
|
||||
externalProxy = null,
|
||||
} = input;
|
||||
|
||||
if (inbound.protocol !== 'hysteria') return '';
|
||||
@@ -635,6 +646,13 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
||||
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
||||
params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.map(hysteriaPinHex).join(','));
|
||||
}
|
||||
// An external-proxy entry can pin a different endpoint's certificate.
|
||||
// Hysteria carries it as hex `pinSHA256` (not the `pcs` other protocols
|
||||
// use), so coerce each entry through hysteriaPinHex like the main pin.
|
||||
if (Array.isArray(externalProxy?.pinnedPeerCertSha256)) {
|
||||
const epPins = externalProxy.pinnedPeerCertSha256.filter(Boolean).map(hysteriaPinHex);
|
||||
if (epPins.length > 0) params.set('pinSHA256', epPins.join(','));
|
||||
}
|
||||
|
||||
const udpMasks = stream.finalmask?.udp;
|
||||
if (Array.isArray(udpMasks)) {
|
||||
@@ -844,6 +862,7 @@ export function genLink(input: GenLinkInput): string {
|
||||
return genHysteriaLink({
|
||||
inbound, address, port, remark,
|
||||
clientAuth: client.auth ?? '',
|
||||
externalProxy,
|
||||
});
|
||||
default:
|
||||
return '';
|
||||
|
||||
@@ -207,6 +207,7 @@ export default function InboundFormModal({
|
||||
sni: '',
|
||||
fingerprint: '',
|
||||
alpn: [],
|
||||
pinnedPeerCertSha256: [],
|
||||
}]);
|
||||
} else {
|
||||
form.setFieldValue(['streamSettings', 'externalProxy'], []);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
.ext-proxy-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ext-proxy-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 10px;
|
||||
background: var(--ant-color-fill-quaternary);
|
||||
}
|
||||
|
||||
.ext-proxy-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ext-proxy-card__title {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.ext-proxy-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ext-proxy-flabel {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.ext-proxy-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ext-proxy-grid--dest {
|
||||
grid-template-columns: 1fr 1.7fr 0.9fr;
|
||||
}
|
||||
|
||||
.ext-proxy-grid--tls {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.ext-proxy-tls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 2px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px dashed var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.ext-proxy-add {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
.ext-proxy-grid--dest,
|
||||
.ext-proxy-grid--tls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,49 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
|
||||
import './external-proxy.css';
|
||||
|
||||
const newEntry = () => ({
|
||||
forceTls: 'same',
|
||||
dest: '',
|
||||
port: 443,
|
||||
remark: '',
|
||||
sni: '',
|
||||
fingerprint: '',
|
||||
alpn: [],
|
||||
pinnedPeerCertSha256: [],
|
||||
});
|
||||
|
||||
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="ext-proxy-field">
|
||||
<span className="ext-proxy-flabel">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExternalProxyForm({
|
||||
toggleExternalProxy,
|
||||
}: {
|
||||
toggleExternalProxy: (on: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const generateRandomPin = (name: number) => {
|
||||
const bytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(bytes);
|
||||
const hash = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
const path = ['streamSettings', 'externalProxy', name, 'pinnedPeerCertSha256'];
|
||||
const current = (form.getFieldValue(path) as string[] | undefined) ?? [];
|
||||
form.setFieldValue(path, [...current, hash]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
noStyle
|
||||
@@ -29,104 +62,138 @@ export default function ExternalProxyForm({
|
||||
<Switch checked={on} onChange={toggleExternalProxy} />
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<Form.List name={['streamSettings', 'externalProxy']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<Form.Item label=" " colon={false}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => add({
|
||||
forceTls: 'same',
|
||||
dest: '',
|
||||
port: 443,
|
||||
remark: '',
|
||||
sni: '',
|
||||
fingerprint: '',
|
||||
alpn: [],
|
||||
})}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item wrapperCol={{ span: 24 }}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} style={{ margin: '8px 0' }}>
|
||||
<Space.Compact block>
|
||||
<Form.Item name={[field.name, 'forceTls']} noStyle>
|
||||
<Select
|
||||
style={{ width: '20%' }}
|
||||
options={[
|
||||
{ value: 'same', label: t('pages.inbounds.same') },
|
||||
{ value: 'none', label: t('none') },
|
||||
{ value: 'tls', label: 'TLS' },
|
||||
]}
|
||||
<Form.Item wrapperCol={{ span: 24 }}>
|
||||
<Form.List name={['streamSettings', 'externalProxy']}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<div className="ext-proxy-list">
|
||||
{fields.map((field, idx) => (
|
||||
<div key={field.key} className="ext-proxy-card">
|
||||
<div className="ext-proxy-card__head">
|
||||
<span className="ext-proxy-card__title">#{idx + 1}</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => remove(field.name)}
|
||||
/>
|
||||
</div>
|
||||
<div className="ext-proxy-grid ext-proxy-grid--dest">
|
||||
<Field label={t('pages.inbounds.form.forceTls')}>
|
||||
<Form.Item name={[field.name, 'forceTls']} noStyle>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
options={[
|
||||
{ value: 'same', label: t('pages.inbounds.same') },
|
||||
{ value: 'none', label: t('none') },
|
||||
{ value: 'tls', label: 'TLS' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('host')}>
|
||||
<Form.Item name={[field.name, 'dest']} noStyle>
|
||||
<Input placeholder={t('host')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('pages.inbounds.port')}>
|
||||
<Form.Item name={[field.name, 'port']} noStyle>
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('pages.inbounds.remark')}>
|
||||
<Form.Item name={[field.name, 'remark']} noStyle>
|
||||
<Input placeholder={t('pages.inbounds.remark')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.streamSettings?.externalProxy?.[field.name]?.forceTls
|
||||
!== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const ft = getFieldValue([
|
||||
'streamSettings', 'externalProxy', field.name, 'forceTls',
|
||||
]);
|
||||
if (ft !== 'tls') return null;
|
||||
return (
|
||||
<div className="ext-proxy-tls">
|
||||
<div className="ext-proxy-grid ext-proxy-grid--tls">
|
||||
<Field label="SNI">
|
||||
<Form.Item name={[field.name, 'sni']} noStyle>
|
||||
<Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label={t('pages.inbounds.form.fingerprint')}>
|
||||
<Form.Item name={[field.name, 'fingerprint']} noStyle>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder={t('pages.inbounds.form.fingerprint')}
|
||||
options={[
|
||||
{ value: '', label: t('pages.inbounds.form.defaultOption') },
|
||||
...Object.values(UTLS_FINGERPRINT).map((fp) => ({
|
||||
value: fp,
|
||||
label: fp,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Field>
|
||||
<Field label="ALPN">
|
||||
<Form.Item name={[field.name, 'alpn']} noStyle>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="ALPN"
|
||||
options={Object.values(ALPN_OPTION).map((a) => ({
|
||||
value: a,
|
||||
label: a,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={t('pages.inbounds.form.pinnedPeerCertSha256')}>
|
||||
<Space.Compact block>
|
||||
<Form.Item name={[field.name, 'pinnedPeerCertSha256']} noStyle>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
|
||||
style={{ width: 'calc(100% - 32px)' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => generateRandomPin(field.name)}
|
||||
title={t('pages.inbounds.form.generateRandomPin')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'dest']} noStyle>
|
||||
<Input style={{ width: '30%' }} placeholder={t('host')} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'port']} noStyle>
|
||||
<InputNumber style={{ width: '15%' }} min={1} max={65535} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'remark']} noStyle>
|
||||
<Input style={{ width: '25%' }} placeholder={t('pages.inbounds.remark')} />
|
||||
</Form.Item>
|
||||
<InputAddon onClick={() => remove(field.name)}>
|
||||
<MinusOutlined />
|
||||
</InputAddon>
|
||||
</Space.Compact>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) =>
|
||||
prev.streamSettings?.externalProxy?.[field.name]?.forceTls
|
||||
!== curr.streamSettings?.externalProxy?.[field.name]?.forceTls
|
||||
}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const ft = getFieldValue([
|
||||
'streamSettings', 'externalProxy', field.name, 'forceTls',
|
||||
]);
|
||||
if (ft !== 'tls') return null;
|
||||
return (
|
||||
<Space.Compact style={{ marginTop: 6 }} block>
|
||||
<Form.Item name={[field.name, 'sni']} noStyle>
|
||||
<Input style={{ width: '30%' }} placeholder={t('pages.inbounds.form.sniPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'fingerprint']} noStyle>
|
||||
<Select
|
||||
style={{ width: '30%' }}
|
||||
placeholder={t('pages.inbounds.form.fingerprint')}
|
||||
options={[
|
||||
{ value: '', label: t('pages.inbounds.form.defaultOption') },
|
||||
...Object.values(UTLS_FINGERPRINT).map((fp) => ({
|
||||
value: fp,
|
||||
label: fp,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'alpn']} noStyle>
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '40%' }}
|
||||
placeholder="ALPN"
|
||||
options={Object.values(ALPN_OPTION).map((a) => ({
|
||||
value: a,
|
||||
label: a,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space.Compact>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
))}
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
className="ext-proxy-add"
|
||||
block
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => add(newEntry())}
|
||||
>
|
||||
{t('add')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -22,5 +22,6 @@ export const ExternalProxyEntrySchema = z.object({
|
||||
UtlsFingerprintSchema.optional(),
|
||||
),
|
||||
alpn: z.array(AlpnSchema).optional(),
|
||||
pinnedPeerCertSha256: z.array(z.string()).optional(),
|
||||
});
|
||||
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
|
||||
|
||||
@@ -196,6 +196,34 @@ describe('genHysteriaLink', () => {
|
||||
'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits an external proxy pin as hex pinSHA256 (not pcs)', () => {
|
||||
const [, raw] = fixtures[0];
|
||||
const typed = InboundSchema.parse(raw);
|
||||
const client = (raw.settings as { clients: Array<{ auth: string }> }).clients[0];
|
||||
|
||||
const link = genHysteriaLink({
|
||||
inbound: typed,
|
||||
address: 'edge.example.com',
|
||||
port: 8443,
|
||||
remark: 'ep-pin',
|
||||
clientAuth: client.auth,
|
||||
externalProxy: {
|
||||
forceTls: 'tls',
|
||||
dest: 'edge.example.com',
|
||||
port: 8443,
|
||||
remark: 'ep-pin',
|
||||
// base64 SHA-256 — must come out hex-normalized for Hysteria.
|
||||
pinnedPeerCertSha256: ['yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ='],
|
||||
},
|
||||
});
|
||||
|
||||
const url = new URL(link);
|
||||
expect(url.searchParams.get('pinSHA256')).toBe(
|
||||
'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
|
||||
);
|
||||
expect(url.searchParams.has('pcs')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('genWireguardLink + genWireguardConfig', () => {
|
||||
@@ -356,3 +384,49 @@ describe('genShadowsocksLink', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('external proxy pinned cert (pcs)', () => {
|
||||
const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
|
||||
const typed = InboundSchema.parse(raw);
|
||||
const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id;
|
||||
|
||||
it('emits the external proxy pin list as pcs when forcing TLS', () => {
|
||||
const link = genVlessLink({
|
||||
inbound: typed,
|
||||
address: 'edge.example.com',
|
||||
port: 8443,
|
||||
forceTls: 'tls',
|
||||
remark: 'ep-pin',
|
||||
clientId,
|
||||
externalProxy: {
|
||||
forceTls: 'tls',
|
||||
dest: 'edge.example.com',
|
||||
port: 8443,
|
||||
remark: 'ep-pin',
|
||||
pinnedPeerCertSha256: ['aa11', 'bb22'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(new URL(link).searchParams.get('pcs')).toBe('aa11,bb22');
|
||||
});
|
||||
|
||||
it('omits pcs when the external proxy forces security off', () => {
|
||||
const link = genVlessLink({
|
||||
inbound: typed,
|
||||
address: 'edge.example.com',
|
||||
port: 8080,
|
||||
forceTls: 'none',
|
||||
remark: 'ep-none',
|
||||
clientId,
|
||||
externalProxy: {
|
||||
forceTls: 'none',
|
||||
dest: 'edge.example.com',
|
||||
port: 8080,
|
||||
remark: 'ep-none',
|
||||
pinnedPeerCertSha256: ['aa11'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(new URL(link).searchParams.has('pcs')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user