mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +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:
parent
df7ccd3a64
commit
e7c11c913a
21 changed files with 503 additions and 103 deletions
|
|
@ -119,6 +119,11 @@ function externalProxyAlpn(value: ExternalProxyEntry['alpn']): string {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function externalProxyPins(value: ExternalProxyEntry['pinnedPeerCertSha256']): string {
|
||||||
|
if (Array.isArray(value)) return value.filter(Boolean).join(',');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function applyExternalProxyTLSObj(
|
function applyExternalProxyTLSObj(
|
||||||
externalProxy: ExternalProxyEntry | null | undefined,
|
externalProxy: ExternalProxyEntry | null | undefined,
|
||||||
obj: Record<string, unknown>,
|
obj: Record<string, unknown>,
|
||||||
|
|
@ -130,6 +135,8 @@ function applyExternalProxyTLSObj(
|
||||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
|
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) obj.fp = externalProxy.fingerprint;
|
||||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||||
if (alpn.length > 0) obj.alpn = alpn;
|
if (alpn.length > 0) obj.alpn = alpn;
|
||||||
|
const pins = externalProxyPins(externalProxy.pinnedPeerCertSha256);
|
||||||
|
if (pins.length > 0) obj.pcs = pins;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenVmessLinkInput {
|
export interface GenVmessLinkInput {
|
||||||
|
|
@ -270,6 +277,8 @@ function applyExternalProxyTLSParams(
|
||||||
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
|
if (externalProxy.fingerprint && externalProxy.fingerprint.length > 0) params.set('fp', externalProxy.fingerprint);
|
||||||
const alpn = externalProxyAlpn(externalProxy.alpn);
|
const alpn = externalProxyAlpn(externalProxy.alpn);
|
||||||
if (alpn.length > 0) params.set('alpn', 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 {
|
export interface GenVlessLinkInput {
|
||||||
|
|
@ -576,6 +585,7 @@ export interface GenHysteriaLinkInput {
|
||||||
port?: number;
|
port?: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
clientAuth: string;
|
clientAuth: string;
|
||||||
|
externalProxy?: ExternalProxyEntry | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hysteria2's pinSHA256 must be a 64-char lowercase hex string — Xray-core
|
// 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,
|
port = inbound.port,
|
||||||
remark = '',
|
remark = '',
|
||||||
clientAuth,
|
clientAuth,
|
||||||
|
externalProxy = null,
|
||||||
} = input;
|
} = input;
|
||||||
|
|
||||||
if (inbound.protocol !== 'hysteria') return '';
|
if (inbound.protocol !== 'hysteria') return '';
|
||||||
|
|
@ -635,6 +646,13 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
||||||
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
if (tls.settings.pinnedPeerCertSha256.length > 0) {
|
||||||
params.set('pinSHA256', tls.settings.pinnedPeerCertSha256.map(hysteriaPinHex).join(','));
|
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;
|
const udpMasks = stream.finalmask?.udp;
|
||||||
if (Array.isArray(udpMasks)) {
|
if (Array.isArray(udpMasks)) {
|
||||||
|
|
@ -844,6 +862,7 @@ export function genLink(input: GenLinkInput): string {
|
||||||
return genHysteriaLink({
|
return genHysteriaLink({
|
||||||
inbound, address, port, remark,
|
inbound, address, port, remark,
|
||||||
clientAuth: client.auth ?? '',
|
clientAuth: client.auth ?? '',
|
||||||
|
externalProxy,
|
||||||
});
|
});
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ export default function InboundFormModal({
|
||||||
sni: '',
|
sni: '',
|
||||||
fingerprint: '',
|
fingerprint: '',
|
||||||
alpn: [],
|
alpn: [],
|
||||||
|
pinnedPeerCertSha256: [],
|
||||||
}]);
|
}]);
|
||||||
} else {
|
} else {
|
||||||
form.setFieldValue(['streamSettings', 'externalProxy'], []);
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
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 { 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({
|
export default function ExternalProxyForm({
|
||||||
toggleExternalProxy,
|
toggleExternalProxy,
|
||||||
}: {
|
}: {
|
||||||
toggleExternalProxy: (on: boolean) => void;
|
toggleExternalProxy: (on: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
noStyle
|
noStyle
|
||||||
|
|
@ -29,33 +62,28 @@ export default function ExternalProxyForm({
|
||||||
<Switch checked={on} onChange={toggleExternalProxy} />
|
<Switch checked={on} onChange={toggleExternalProxy} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{on && (
|
{on && (
|
||||||
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
<Form.List name={['streamSettings', 'externalProxy']}>
|
<Form.List name={['streamSettings', 'externalProxy']}>
|
||||||
{(fields, { add, remove }) => (
|
{(fields, { add, remove }) => (
|
||||||
<>
|
<>
|
||||||
<Form.Item label=" " colon={false}>
|
<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
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="text"
|
||||||
onClick={() => add({
|
danger
|
||||||
forceTls: 'same',
|
icon={<DeleteOutlined />}
|
||||||
dest: '',
|
onClick={() => remove(field.name)}
|
||||||
port: 443,
|
/>
|
||||||
remark: '',
|
</div>
|
||||||
sni: '',
|
<div className="ext-proxy-grid ext-proxy-grid--dest">
|
||||||
fingerprint: '',
|
<Field label={t('pages.inbounds.form.forceTls')}>
|
||||||
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>
|
<Form.Item name={[field.name, 'forceTls']} noStyle>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '20%' }}
|
style={{ width: '100%' }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'same', label: t('pages.inbounds.same') },
|
{ value: 'same', label: t('pages.inbounds.same') },
|
||||||
{ value: 'none', label: t('none') },
|
{ value: 'none', label: t('none') },
|
||||||
|
|
@ -63,19 +91,23 @@ export default function ExternalProxyForm({
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('host')}>
|
||||||
<Form.Item name={[field.name, 'dest']} noStyle>
|
<Form.Item name={[field.name, 'dest']} noStyle>
|
||||||
<Input style={{ width: '30%' }} placeholder={t('host')} />
|
<Input placeholder={t('host')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('pages.inbounds.port')}>
|
||||||
<Form.Item name={[field.name, 'port']} noStyle>
|
<Form.Item name={[field.name, 'port']} noStyle>
|
||||||
<InputNumber style={{ width: '15%' }} min={1} max={65535} />
|
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label={t('pages.inbounds.remark')}>
|
||||||
<Form.Item name={[field.name, 'remark']} noStyle>
|
<Form.Item name={[field.name, 'remark']} noStyle>
|
||||||
<Input style={{ width: '25%' }} placeholder={t('pages.inbounds.remark')} />
|
<Input placeholder={t('pages.inbounds.remark')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<InputAddon onClick={() => remove(field.name)}>
|
</Field>
|
||||||
<MinusOutlined />
|
|
||||||
</InputAddon>
|
|
||||||
</Space.Compact>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
noStyle
|
noStyle
|
||||||
shouldUpdate={(prev, curr) =>
|
shouldUpdate={(prev, curr) =>
|
||||||
|
|
@ -89,13 +121,17 @@ export default function ExternalProxyForm({
|
||||||
]);
|
]);
|
||||||
if (ft !== 'tls') return null;
|
if (ft !== 'tls') return null;
|
||||||
return (
|
return (
|
||||||
<Space.Compact style={{ marginTop: 6 }} block>
|
<div className="ext-proxy-tls">
|
||||||
|
<div className="ext-proxy-grid ext-proxy-grid--tls">
|
||||||
|
<Field label="SNI">
|
||||||
<Form.Item name={[field.name, 'sni']} noStyle>
|
<Form.Item name={[field.name, 'sni']} noStyle>
|
||||||
<Input style={{ width: '30%' }} placeholder={t('pages.inbounds.form.sniPlaceholder')} />
|
<Input placeholder={t('pages.inbounds.form.sniPlaceholder')} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Field>
|
||||||
|
<Field label={t('pages.inbounds.form.fingerprint')}>
|
||||||
<Form.Item name={[field.name, 'fingerprint']} noStyle>
|
<Form.Item name={[field.name, 'fingerprint']} noStyle>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: '30%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder={t('pages.inbounds.form.fingerprint')}
|
placeholder={t('pages.inbounds.form.fingerprint')}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: t('pages.inbounds.form.defaultOption') },
|
{ value: '', label: t('pages.inbounds.form.defaultOption') },
|
||||||
|
|
@ -106,10 +142,12 @@ export default function ExternalProxyForm({
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</Field>
|
||||||
|
<Field label="ALPN">
|
||||||
<Form.Item name={[field.name, 'alpn']} noStyle>
|
<Form.Item name={[field.name, 'alpn']} noStyle>
|
||||||
<Select
|
<Select
|
||||||
mode="multiple"
|
mode="multiple"
|
||||||
style={{ width: '40%' }}
|
style={{ width: '100%' }}
|
||||||
placeholder="ALPN"
|
placeholder="ALPN"
|
||||||
options={Object.values(ALPN_OPTION).map((a) => ({
|
options={Object.values(ALPN_OPTION).map((a) => ({
|
||||||
value: a,
|
value: a,
|
||||||
|
|
@ -117,16 +155,45 @@ export default function ExternalProxyForm({
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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>
|
</Space.Compact>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Form.Item>
|
</div>
|
||||||
|
<Button
|
||||||
|
className="ext-proxy-add"
|
||||||
|
block
|
||||||
|
type="dashed"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => add(newEntry())}
|
||||||
|
>
|
||||||
|
{t('add')}
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Form.List>
|
</Form.List>
|
||||||
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,6 @@ export const ExternalProxyEntrySchema = z.object({
|
||||||
UtlsFingerprintSchema.optional(),
|
UtlsFingerprintSchema.optional(),
|
||||||
),
|
),
|
||||||
alpn: z.array(AlpnSchema).optional(),
|
alpn: z.array(AlpnSchema).optional(),
|
||||||
|
pinnedPeerCertSha256: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
|
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,34 @@ describe('genHysteriaLink', () => {
|
||||||
'c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4',
|
'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', () => {
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -667,8 +667,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
||||||
}
|
}
|
||||||
epRemark, _ := ep["remark"].(string)
|
epRemark, _ := ep["remark"].(string)
|
||||||
|
|
||||||
|
epParams := cloneStringMap(params)
|
||||||
|
applyExternalProxyHysteriaParams(ep, epParams)
|
||||||
|
|
||||||
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, int(portF))
|
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, int(portF))
|
||||||
links = append(links, buildLinkWithParams(link, params, s.genRemark(inbound, email, epRemark)))
|
links = append(links, buildLinkWithParams(link, epParams, s.genRemark(inbound, email, epRemark)))
|
||||||
}
|
}
|
||||||
return strings.Join(links, "\n")
|
return strings.Join(links, "\n")
|
||||||
}
|
}
|
||||||
|
|
@ -1017,7 +1020,7 @@ func buildVmessLink(obj map[string]any) string {
|
||||||
func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]any {
|
func cloneVmessShareObj(baseObj map[string]any, newSecurity string) map[string]any {
|
||||||
newObj := map[string]any{}
|
newObj := map[string]any{}
|
||||||
for key, value := range baseObj {
|
for key, value := range baseObj {
|
||||||
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp")) {
|
if !(newSecurity == "none" && (key == "alpn" || key == "sni" || key == "fp" || key == "pcs")) {
|
||||||
newObj[key] = value
|
newObj[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1037,6 +1040,9 @@ func applyExternalProxyTLSObj(ep map[string]any, obj map[string]any, security st
|
||||||
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
||||||
obj["alpn"] = alpn
|
obj["alpn"] = alpn
|
||||||
}
|
}
|
||||||
|
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||||
|
obj["pcs"] = joinAnyStrings(pins)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
|
func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, security string) {
|
||||||
|
|
@ -1052,6 +1058,29 @@ func applyExternalProxyTLSParams(ep map[string]any, params map[string]string, se
|
||||||
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
if alpn, ok := externalProxyALPN(ep["alpn"]); ok {
|
||||||
params["alpn"] = alpn
|
params["alpn"] = alpn
|
||||||
}
|
}
|
||||||
|
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||||
|
params["pcs"] = joinAnyStrings(pins)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyExternalProxyHysteriaParams overrides the cert pin for a single
|
||||||
|
// external-proxy entry on a Hysteria link. Hysteria carries the pin as a hex
|
||||||
|
// `pinSHA256` (not the `pcs` the URL-param protocols use), so each entry is
|
||||||
|
// coerced through hysteriaPinHex like the main pin. sni/fp/alpn are left as
|
||||||
|
// the inbound's own — Hysteria external proxies are typically alternate
|
||||||
|
// endpoints (port-hop / CDN) fronting the same certificate.
|
||||||
|
func applyExternalProxyHysteriaParams(ep map[string]any, params map[string]string) {
|
||||||
|
pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"])
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hexPins := make([]string, 0, len(pins))
|
||||||
|
for _, p := range pins {
|
||||||
|
if s, ok := p.(string); ok {
|
||||||
|
hexPins = append(hexPins, hysteriaPinHex(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params["pinSHA256"] = strings.Join(hexPins, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloneStreamForExternalProxy returns a shallow clone of stream with
|
// cloneStreamForExternalProxy returns a shallow clone of stream with
|
||||||
|
|
@ -1096,6 +1125,14 @@ func applyExternalProxyTLSToStream(ep map[string]any, stream map[string]any, sec
|
||||||
if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
|
if alpn, ok := externalProxyALPNList(ep["alpn"]); ok {
|
||||||
tlsSettings["alpn"] = alpn
|
tlsSettings["alpn"] = alpn
|
||||||
}
|
}
|
||||||
|
if pins, ok := externalProxyPins(ep["pinnedPeerCertSha256"]); ok {
|
||||||
|
settings, _ := tlsSettings["settings"].(map[string]any)
|
||||||
|
if settings == nil {
|
||||||
|
settings = map[string]any{}
|
||||||
|
tlsSettings["settings"] = settings
|
||||||
|
}
|
||||||
|
settings["pinnedPeerCertSha256"] = pins
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func externalProxySNI(ep map[string]any) (string, bool) {
|
func externalProxySNI(ep map[string]any) (string, bool) {
|
||||||
|
|
@ -1165,6 +1202,43 @@ func externalProxyALPNList(value any) ([]any, bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// externalProxyPins extracts an external-proxy entry's pinnedPeerCertSha256
|
||||||
|
// as a []any of non-empty strings. The []any element type matches what the
|
||||||
|
// JSON/Clash sub builders expect when reading the value back off the cloned
|
||||||
|
// stream's tlsSettings.settings.
|
||||||
|
func externalProxyPins(value any) ([]any, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []string:
|
||||||
|
out := make([]any, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if item != "" {
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, len(out) > 0
|
||||||
|
case []any:
|
||||||
|
out := make([]any, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
if s, ok := item.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, len(out) > 0
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinAnyStrings(items []any) string {
|
||||||
|
parts := make([]string, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if s, ok := item.(string); ok {
|
||||||
|
parts = append(parts, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
func (s *SubService) buildVmessExternalProxyLinks(externalProxies []any, baseObj map[string]any, inbound *model.Inbound, email string) string {
|
||||||
var links strings.Builder
|
var links strings.Builder
|
||||||
for index, externalProxy := range externalProxies {
|
for index, externalProxy := range externalProxies {
|
||||||
|
|
@ -1204,8 +1278,8 @@ func buildLinkWithParams(link string, params map[string]string, fragment string)
|
||||||
|
|
||||||
// buildLinkWithParamsAndSecurity is buildLinkWithParams plus an
|
// buildLinkWithParamsAndSecurity is buildLinkWithParams plus an
|
||||||
// external-proxy override: the `security` key in params is replaced with
|
// external-proxy override: the `security` key in params is replaced with
|
||||||
// the supplied value, and TLS hint fields (alpn/sni/fp) are stripped when
|
// the supplied value, and TLS hint fields (alpn/sni/fp/pcs) are stripped
|
||||||
// the override is `none`.
|
// when the override is `none`.
|
||||||
func buildLinkWithParamsAndSecurity(link string, params map[string]string, fragment, security string, omitTLSFields bool) string {
|
func buildLinkWithParamsAndSecurity(link string, params map[string]string, fragment, security string, omitTLSFields bool) string {
|
||||||
return appendQueryAndFragment(link, params, fragment, security, omitTLSFields)
|
return appendQueryAndFragment(link, params, fragment, security, omitTLSFields)
|
||||||
}
|
}
|
||||||
|
|
@ -1220,7 +1294,7 @@ func appendQueryAndFragment(link string, params map[string]string, fragment, sec
|
||||||
if securityOverride != "" && k == "security" {
|
if securityOverride != "" && k == "security" {
|
||||||
v = securityOverride
|
v = securityOverride
|
||||||
}
|
}
|
||||||
if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp") {
|
if omitTLSFields && (k == "alpn" || k == "sni" || k == "fp" || k == "pcs") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
q.Set(k, v)
|
q.Set(k, v)
|
||||||
|
|
|
||||||
|
|
@ -617,6 +617,85 @@ func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
|
||||||
|
params := map[string]string{"security": "tls"}
|
||||||
|
ep := map[string]any{
|
||||||
|
"dest": "proxy.example.com",
|
||||||
|
"pinnedPeerCertSha256": []any{"aa11", "bb22"},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyExternalProxyTLSParams(ep, params, "tls")
|
||||||
|
|
||||||
|
if params["pcs"] != "aa11,bb22" {
|
||||||
|
t.Fatalf("pcs = %q, want aa11,bb22", params["pcs"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyExternalProxyTLSObj_SetsPinnedPeerCert(t *testing.T) {
|
||||||
|
obj := map[string]any{"tls": "tls"}
|
||||||
|
ep := map[string]any{
|
||||||
|
"dest": "proxy.example.com",
|
||||||
|
"pinnedPeerCertSha256": []any{"aa11"},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyExternalProxyTLSObj(ep, obj, "tls")
|
||||||
|
|
||||||
|
if obj["pcs"] != "aa11" {
|
||||||
|
t.Fatalf("pcs = %v, want aa11", obj["pcs"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyExternalProxyTLSToStream_SetsPinnedPeerCert(t *testing.T) {
|
||||||
|
stream := map[string]any{
|
||||||
|
"security": "tls",
|
||||||
|
"tlsSettings": map[string]any{"serverName": "upstream.example.com"},
|
||||||
|
}
|
||||||
|
ep := map[string]any{"dest": "edge.example.com", "pinnedPeerCertSha256": []any{"aa11", "bb22"}}
|
||||||
|
|
||||||
|
working := cloneStreamForExternalProxy(stream)
|
||||||
|
applyExternalProxyTLSToStream(ep, working, "tls")
|
||||||
|
|
||||||
|
ts := working["tlsSettings"].(map[string]any)
|
||||||
|
settings, _ := ts["settings"].(map[string]any)
|
||||||
|
pins, ok := settings["pinnedPeerCertSha256"].([]any)
|
||||||
|
if !ok || len(pins) != 2 || pins[0] != "aa11" || pins[1] != "bb22" {
|
||||||
|
t.Fatalf("pinnedPeerCertSha256 = %v, want [aa11 bb22]", settings["pinnedPeerCertSha256"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyExternalProxyHysteriaParams_PinIsHexNormalized(t *testing.T) {
|
||||||
|
// base64 SHA-256 pin must come out as bare lowercase hex for Hysteria's
|
||||||
|
// pinSHA256, which other (pcs) protocols leave untouched.
|
||||||
|
params := map[string]string{"security": "tls", "sni": "server.example.com"}
|
||||||
|
ep := map[string]any{
|
||||||
|
"dest": "edge.example.com",
|
||||||
|
"pinnedPeerCertSha256": []any{"yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ="},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyExternalProxyHysteriaParams(ep, params)
|
||||||
|
|
||||||
|
if params["pinSHA256"] != "c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4" {
|
||||||
|
t.Fatalf("pinSHA256 = %q, want hex-normalized pin", params["pinSHA256"])
|
||||||
|
}
|
||||||
|
if _, ok := params["pcs"]; ok {
|
||||||
|
t.Fatalf("pcs must not be set for Hysteria, got %v", params)
|
||||||
|
}
|
||||||
|
if params["sni"] != "server.example.com" {
|
||||||
|
t.Fatalf("sni = %q, want inbound sni preserved (no override for Hysteria)", params["sni"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyExternalProxyHysteriaParams_NoPinLeavesMainPin(t *testing.T) {
|
||||||
|
params := map[string]string{"security": "tls", "pinSHA256": "deadbeef"}
|
||||||
|
ep := map[string]any{"dest": "edge.example.com"}
|
||||||
|
|
||||||
|
applyExternalProxyHysteriaParams(ep, params)
|
||||||
|
|
||||||
|
if params["pinSHA256"] != "deadbeef" {
|
||||||
|
t.Fatalf("pinSHA256 = %q, want main pin preserved when proxy has none", params["pinSHA256"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
|
func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"security": "none",
|
"security": "none",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "معامل CWND",
|
"cwndMultiplier": "معامل CWND",
|
||||||
"maxSendingWindow": "أقصى نافذة إرسال",
|
"maxSendingWindow": "أقصى نافذة إرسال",
|
||||||
"externalProxy": "وكيل خارجي",
|
"externalProxy": "وكيل خارجي",
|
||||||
|
"forceTls": "فرض TLS",
|
||||||
"sniPlaceholder": "SNI (افتراضياً host)",
|
"sniPlaceholder": "SNI (افتراضياً host)",
|
||||||
"fingerprint": "بصمة",
|
"fingerprint": "بصمة",
|
||||||
"defaultOption": "افتراضي",
|
"defaultOption": "افتراضي",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "CWND Multiplier",
|
"cwndMultiplier": "CWND Multiplier",
|
||||||
"maxSendingWindow": "Max Sending Window",
|
"maxSendingWindow": "Max Sending Window",
|
||||||
"externalProxy": "External Proxy",
|
"externalProxy": "External Proxy",
|
||||||
|
"forceTls": "Force TLS",
|
||||||
"sniPlaceholder": "SNI (defaults to host)",
|
"sniPlaceholder": "SNI (defaults to host)",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Default",
|
"defaultOption": "Default",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "Multiplicador CWND",
|
"cwndMultiplier": "Multiplicador CWND",
|
||||||
"maxSendingWindow": "Máx. ventana de envío",
|
"maxSendingWindow": "Máx. ventana de envío",
|
||||||
"externalProxy": "Proxy externo",
|
"externalProxy": "Proxy externo",
|
||||||
|
"forceTls": "Forzar TLS",
|
||||||
"sniPlaceholder": "SNI (por defecto = host)",
|
"sniPlaceholder": "SNI (por defecto = host)",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Por defecto",
|
"defaultOption": "Por defecto",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "ضریب CWND",
|
"cwndMultiplier": "ضریب CWND",
|
||||||
"maxSendingWindow": "حداکثر پنجره ارسال",
|
"maxSendingWindow": "حداکثر پنجره ارسال",
|
||||||
"externalProxy": "پراکسی خارجی",
|
"externalProxy": "پراکسی خارجی",
|
||||||
|
"forceTls": "اجبار TLS",
|
||||||
"sniPlaceholder": "SNI (پیشفرض همان host)",
|
"sniPlaceholder": "SNI (پیشفرض همان host)",
|
||||||
"fingerprint": "اثرانگشت",
|
"fingerprint": "اثرانگشت",
|
||||||
"defaultOption": "پیشفرض",
|
"defaultOption": "پیشفرض",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "Pengganda CWND",
|
"cwndMultiplier": "Pengganda CWND",
|
||||||
"maxSendingWindow": "Maks. jendela pengiriman",
|
"maxSendingWindow": "Maks. jendela pengiriman",
|
||||||
"externalProxy": "Proxy eksternal",
|
"externalProxy": "Proxy eksternal",
|
||||||
|
"forceTls": "Paksa TLS",
|
||||||
"sniPlaceholder": "SNI (default = host)",
|
"sniPlaceholder": "SNI (default = host)",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Default",
|
"defaultOption": "Default",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "CWND 倍率",
|
"cwndMultiplier": "CWND 倍率",
|
||||||
"maxSendingWindow": "最大送信ウィンドウ",
|
"maxSendingWindow": "最大送信ウィンドウ",
|
||||||
"externalProxy": "外部プロキシ",
|
"externalProxy": "外部プロキシ",
|
||||||
|
"forceTls": "TLS を強制",
|
||||||
"sniPlaceholder": "SNI (デフォルトは host)",
|
"sniPlaceholder": "SNI (デフォルトは host)",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "デフォルト",
|
"defaultOption": "デフォルト",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "Multiplicador CWND",
|
"cwndMultiplier": "Multiplicador CWND",
|
||||||
"maxSendingWindow": "Máx. janela de envio",
|
"maxSendingWindow": "Máx. janela de envio",
|
||||||
"externalProxy": "Proxy externo",
|
"externalProxy": "Proxy externo",
|
||||||
|
"forceTls": "Forçar TLS",
|
||||||
"sniPlaceholder": "SNI (padrão = host)",
|
"sniPlaceholder": "SNI (padrão = host)",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Padrão",
|
"defaultOption": "Padrão",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "Множитель CWND",
|
"cwndMultiplier": "Множитель CWND",
|
||||||
"maxSendingWindow": "Макс. окно отправки",
|
"maxSendingWindow": "Макс. окно отправки",
|
||||||
"externalProxy": "External Proxy",
|
"externalProxy": "External Proxy",
|
||||||
|
"forceTls": "Принудительный TLS",
|
||||||
"sniPlaceholder": "SNI (по умолчанию = host)",
|
"sniPlaceholder": "SNI (по умолчанию = host)",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "По умолчанию",
|
"defaultOption": "По умолчанию",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "CWND çarpanı",
|
"cwndMultiplier": "CWND çarpanı",
|
||||||
"maxSendingWindow": "Maks. gönderme penceresi",
|
"maxSendingWindow": "Maks. gönderme penceresi",
|
||||||
"externalProxy": "Harici proxy",
|
"externalProxy": "Harici proxy",
|
||||||
|
"forceTls": "TLS zorla",
|
||||||
"sniPlaceholder": "SNI (varsayılan host)",
|
"sniPlaceholder": "SNI (varsayılan host)",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Varsayılan",
|
"defaultOption": "Varsayılan",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "Множник CWND",
|
"cwndMultiplier": "Множник CWND",
|
||||||
"maxSendingWindow": "Макс. вікно відправки",
|
"maxSendingWindow": "Макс. вікно відправки",
|
||||||
"externalProxy": "External Proxy",
|
"externalProxy": "External Proxy",
|
||||||
|
"forceTls": "Примусовий TLS",
|
||||||
"sniPlaceholder": "SNI (за замовчуванням = host)",
|
"sniPlaceholder": "SNI (за замовчуванням = host)",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "За замовчуванням",
|
"defaultOption": "За замовчуванням",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "Hệ số CWND",
|
"cwndMultiplier": "Hệ số CWND",
|
||||||
"maxSendingWindow": "Cửa sổ gửi tối đa",
|
"maxSendingWindow": "Cửa sổ gửi tối đa",
|
||||||
"externalProxy": "Proxy ngoài",
|
"externalProxy": "Proxy ngoài",
|
||||||
|
"forceTls": "Bắt buộc TLS",
|
||||||
"sniPlaceholder": "SNI (mặc định = host)",
|
"sniPlaceholder": "SNI (mặc định = host)",
|
||||||
"fingerprint": "Fingerprint",
|
"fingerprint": "Fingerprint",
|
||||||
"defaultOption": "Mặc định",
|
"defaultOption": "Mặc định",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "CWND 倍数",
|
"cwndMultiplier": "CWND 倍数",
|
||||||
"maxSendingWindow": "最大发送窗口",
|
"maxSendingWindow": "最大发送窗口",
|
||||||
"externalProxy": "外部代理",
|
"externalProxy": "外部代理",
|
||||||
|
"forceTls": "强制 TLS",
|
||||||
"sniPlaceholder": "SNI (默认为 host)",
|
"sniPlaceholder": "SNI (默认为 host)",
|
||||||
"fingerprint": "指纹",
|
"fingerprint": "指纹",
|
||||||
"defaultOption": "默认",
|
"defaultOption": "默认",
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,7 @@
|
||||||
"cwndMultiplier": "CWND 倍數",
|
"cwndMultiplier": "CWND 倍數",
|
||||||
"maxSendingWindow": "最大發送視窗",
|
"maxSendingWindow": "最大發送視窗",
|
||||||
"externalProxy": "外部代理",
|
"externalProxy": "外部代理",
|
||||||
|
"forceTls": "強制 TLS",
|
||||||
"sniPlaceholder": "SNI (預設為 host)",
|
"sniPlaceholder": "SNI (預設為 host)",
|
||||||
"fingerprint": "指紋",
|
"fingerprint": "指紋",
|
||||||
"defaultOption": "預設",
|
"defaultOption": "預設",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue