From af3c808444589c1e4ca25181a38d54d045e2a032 Mon Sep 17 00:00:00 2001 From: Sanaei Date: Mon, 8 Jun 2026 13:15:51 +0200 Subject: [PATCH] fix: default hysteria tls to no utls fingerprint --- frontend/src/lib/xray/inbound-tls-defaults.ts | 34 ++++++++++ .../src/lib/xray/stream-wire-normalize.ts | 19 ++++++ .../pages/inbounds/form/InboundFormModal.tsx | 15 +--- .../pages/inbounds/form/useSecurityActions.ts | 16 +---- .../src/schemas/protocols/security/tls.ts | 5 +- frontend/src/test/inbound-defaults.test.ts | 16 +++++ .../src/test/stream-wire-normalize.test.ts | 68 +++++++++++++++++++ 7 files changed, 145 insertions(+), 28 deletions(-) create mode 100644 frontend/src/lib/xray/inbound-tls-defaults.ts diff --git a/frontend/src/lib/xray/inbound-tls-defaults.ts b/frontend/src/lib/xray/inbound-tls-defaults.ts new file mode 100644 index 00000000..4ef9ad11 --- /dev/null +++ b/frontend/src/lib/xray/inbound-tls-defaults.ts @@ -0,0 +1,34 @@ +import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; + +function defaultCertificate(): Record { + return { + useFile: true, + certificateFile: '', + keyFile: '', + certificate: [], + key: [], + ocspStapling: 3600, + oneTimeLoading: false, + usage: 'encipherment', + buildChain: false, + }; +} + +export function createTlsSettingsWithDefaultCert(): Record { + const tls = TlsStreamSettingsSchema.parse({}) as Record; + tls.certificates = [defaultCertificate()]; + return tls; +} + +export function createHysteriaTlsSettingsWithDefaultCert(): Record { + const tls = createTlsSettingsWithDefaultCert(); + tls.alpn = ['h3']; + + const settings = tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings) + ? { ...(tls.settings as Record) } + : {}; + settings.fingerprint = ''; + tls.settings = settings; + + return tls; +} diff --git a/frontend/src/lib/xray/stream-wire-normalize.ts b/frontend/src/lib/xray/stream-wire-normalize.ts index f18b47af..5cb8dfbd 100644 --- a/frontend/src/lib/xray/stream-wire-normalize.ts +++ b/frontend/src/lib/xray/stream-wire-normalize.ts @@ -95,6 +95,20 @@ function dropZeroNumbers(obj: Record, keys: readonly string[]): } } +function normalizeTlsForWire(raw: Record): Record { + const out: Record = { ...raw }; + if (out.fingerprint === '') delete out.fingerprint; + + const settings = out.settings; + if (isRecord(settings)) { + const settingsOut: Record = { ...settings }; + if (settingsOut.fingerprint === '') delete settingsOut.fingerprint; + out.settings = settingsOut; + } + + return out; +} + export function normalizeXhttpForWire( raw: Record, side: StreamWireSide, @@ -211,6 +225,11 @@ export function normalizeStreamSettingsForWire( out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side); } + const tls = out.tlsSettings; + if (isRecord(tls)) { + out.tlsSettings = normalizeTlsForWire(tls); + } + const sockopt = out.sockopt; if (isRecord(sockopt)) { const normalized = normalizeSockoptForWire(sockopt); diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index b748558c..1311e8b9 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -36,7 +36,7 @@ import { antdRule } from '@/utils/zodForm'; import { Protocols } from '@/schemas/primitives'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria'; -import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; +import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults'; import { SniffingSchema } from '@/schemas/primitives/sniffing'; import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp'; import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp'; @@ -351,22 +351,11 @@ export default function InboundFormModal({ // snap back to TCP so the standard network selector has a valid // starting point. if (next === Protocols.HYSTERIA) { - const tls = TlsStreamSettingsSchema.parse({}) as Record; - tls.certificates = [{ - useFile: true, - certificateFile: '', - keyFile: '', - certificate: [], - key: [], - oneTimeLoading: false, - usage: 'encipherment', - buildChain: false, - }]; form.setFieldValue('streamSettings', { network: 'hysteria', security: 'tls', hysteriaSettings: HysteriaStreamSettingsSchema.parse({}), - tlsSettings: tls, + tlsSettings: createHysteriaTlsSettingsWithDefaultCert(), // Hysteria2 needs an obfs wrapper on the FinalMask side; seed // it with salamander + a random password so the listener boots // with a usable default. Re-selecting Hysteria from another diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts index 0f1c71c4..a4cb529e 100644 --- a/frontend/src/pages/inbounds/form/useSecurityActions.ts +++ b/frontend/src/pages/inbounds/form/useSecurityActions.ts @@ -5,7 +5,7 @@ import type { MessageInstance } from 'antd/es/message/interface'; import { HttpUtil, RandomUtil } from '@/utils'; import { getRandomRealityTarget } from '@/models/reality-targets'; -import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls'; +import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults'; import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality'; import type { InboundFormValues } from '@/schemas/forms/inbound-form'; @@ -160,19 +160,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS delete cleaned.tlsSettings; delete cleaned.realitySettings; if (next === 'tls') { - const tls = TlsStreamSettingsSchema.parse({}) as Record; - tls.certificates = [{ - useFile: true, - certificateFile: '', - keyFile: '', - certificate: [], - key: [], - ocspStapling: 3600, - oneTimeLoading: false, - usage: 'encipherment', - buildChain: false, - }]; - cleaned.tlsSettings = tls; + cleaned.tlsSettings = createTlsSettingsWithDefaultCert(); } if (next === 'reality') { const reality = RealityStreamSettingsSchema.parse({}) as Record; diff --git a/frontend/src/schemas/protocols/security/tls.ts b/frontend/src/schemas/protocols/security/tls.ts index ce3d2f37..8fe27301 100644 --- a/frontend/src/schemas/protocols/security/tls.ts +++ b/frontend/src/schemas/protocols/security/tls.ts @@ -22,6 +22,9 @@ export const UtlsFingerprintSchema = z.enum([ ]); export type UtlsFingerprint = z.infer; +export const TlsFingerprintSchema = z.union([UtlsFingerprintSchema, z.literal('')]); +export type TlsFingerprint = z.infer; + export const AlpnSchema = z.enum(['h3', 'h2', 'http/1.1']); export type Alpn = z.infer; @@ -51,7 +54,7 @@ export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]); export type TlsCert = z.infer; export const TlsClientSettingsSchema = z.object({ - fingerprint: UtlsFingerprintSchema.default('chrome'), + fingerprint: TlsFingerprintSchema.default('chrome'), echConfigList: z.string().default(''), pinnedPeerCertSha256: z.array(z.string()).default([]), }); diff --git a/frontend/src/test/inbound-defaults.test.ts b/frontend/src/test/inbound-defaults.test.ts index 2e4487b8..36dcfb00 100644 --- a/frontend/src/test/inbound-defaults.test.ts +++ b/frontend/src/test/inbound-defaults.test.ts @@ -16,6 +16,7 @@ import { createDefaultVmessInboundSettings, createDefaultWireguardInboundSettings, } from '@/lib/xray/inbound-defaults'; +import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults'; import { HttpInboundSettingsSchema } from '@/schemas/protocols/inbound/http'; import { HysteriaClientSchema, HysteriaInboundSettingsSchema } from '@/schemas/protocols/inbound/hysteria'; import { MixedInboundSettingsSchema } from '@/schemas/protocols/inbound/mixed'; @@ -147,3 +148,18 @@ describe('createDefault*InboundSettings factories', () => { expect(WireguardInboundSettingsSchema.parse(s)).toEqual(s); }); }); + +describe('createHysteriaTlsSettingsWithDefaultCert', () => { + it('defaults Hysteria TLS to uTLS None and h3 ALPN', () => { + const tls = createHysteriaTlsSettingsWithDefaultCert(); + expect(tls.alpn).toEqual(['h3']); + expect((tls.settings as Record).fingerprint).toBe(''); + expect(tls.certificates).toEqual([ + expect.objectContaining({ + useFile: true, + certificateFile: '', + keyFile: '', + }), + ]); + }); +}); diff --git a/frontend/src/test/stream-wire-normalize.test.ts b/frontend/src/test/stream-wire-normalize.test.ts index d40e49d5..33e53144 100644 --- a/frontend/src/test/stream-wire-normalize.test.ts +++ b/frontend/src/test/stream-wire-normalize.test.ts @@ -9,6 +9,7 @@ import { normalizeXhttpForWire, validateRealityTarget, } from '@/lib/xray/stream-wire-normalize'; +import { InboundFormSchema } from '@/schemas/forms/inbound-form'; import type { InboundFormValues } from '@/schemas/forms/inbound-form'; describe('validateRealityTarget', () => { @@ -150,6 +151,28 @@ describe('normalizeStreamSettingsForWire reality', () => { }); }); +describe('normalizeStreamSettingsForWire tls', () => { + it('drops empty uTLS fingerprints from inbound and outbound TLS shapes', () => { + const out = normalizeStreamSettingsForWire({ + network: 'hysteria', + security: 'tls', + tlsSettings: { + fingerprint: '', + settings: { + fingerprint: '', + echConfigList: '', + }, + }, + }, { side: 'inbound' }); + + const tls = out.tlsSettings as Record; + const settings = tls.settings as Record; + expect(tls).not.toHaveProperty('fingerprint'); + expect(settings).not.toHaveProperty('fingerprint'); + expect(settings.echConfigList).toBe(''); + }); +}); + describe('inbound formValuesToWirePayload integration', () => { it('emits lean stream-one xhttp + sockopt on save', () => { const values = { @@ -209,6 +232,51 @@ describe('inbound formValuesToWirePayload integration', () => { const realitySettings = reality.settings as Record; expect(realitySettings.publicKey).toBe('pub'); }); + + it('accepts Hysteria TLS with uTLS None and omits fingerprint on save', () => { + const values = { + remark: 'hy2', + enable: true, + port: 443, + listen: '', + tag: 'hy2-443', + expiryTime: 0, + sniffing: { enabled: false }, + up: 0, + down: 0, + total: 0, + trafficReset: 'never', + lastTrafficResetTime: 0, + nodeId: null, + protocol: 'hysteria', + settings: { version: 2, clients: [] }, + streamSettings: { + network: 'hysteria', + security: 'tls', + hysteriaSettings: { + version: 2, + auth: 'auth', + udpIdleTimeout: 60, + }, + tlsSettings: { + alpn: ['h3'], + settings: { + fingerprint: '', + }, + }, + }, + }; + + const parsed = InboundFormSchema.safeParse(values); + expect(parsed.success).toBe(true); + if (!parsed.success) throw parsed.error; + + const payload = formValuesToWirePayload(parsed.data); + const stream = JSON.parse(payload.streamSettings) as Record; + const tls = stream.tlsSettings as Record; + const settings = tls.settings as Record; + expect(settings).not.toHaveProperty('fingerprint'); + }); }); describe('freedom outbound sockopt wire payload', () => {