fix: default hysteria tls to no utls fingerprint

This commit is contained in:
Sanaei
2026-06-08 13:15:51 +02:00
parent 98ba88037c
commit af3c808444
7 changed files with 145 additions and 28 deletions

View File

@@ -0,0 +1,34 @@
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
function defaultCertificate(): Record<string, unknown> {
return {
useFile: true,
certificateFile: '',
keyFile: '',
certificate: [],
key: [],
ocspStapling: 3600,
oneTimeLoading: false,
usage: 'encipherment',
buildChain: false,
};
}
export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [defaultCertificate()];
return tls;
}
export function createHysteriaTlsSettingsWithDefaultCert(): Record<string, unknown> {
const tls = createTlsSettingsWithDefaultCert();
tls.alpn = ['h3'];
const settings = tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
? { ...(tls.settings as Record<string, unknown>) }
: {};
settings.fingerprint = '';
tls.settings = settings;
return tls;
}

View File

@@ -95,6 +95,20 @@ function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]):
}
}
function normalizeTlsForWire(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw };
if (out.fingerprint === '') delete out.fingerprint;
const settings = out.settings;
if (isRecord(settings)) {
const settingsOut: Record<string, unknown> = { ...settings };
if (settingsOut.fingerprint === '') delete settingsOut.fingerprint;
out.settings = settingsOut;
}
return out;
}
export function normalizeXhttpForWire(
raw: Record<string, unknown>,
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);

View File

@@ -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<string, unknown>;
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

View File

@@ -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<string, unknown>;
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<string, unknown>;

View File

@@ -22,6 +22,9 @@ export const UtlsFingerprintSchema = z.enum([
]);
export type UtlsFingerprint = z.infer<typeof UtlsFingerprintSchema>;
export const TlsFingerprintSchema = z.union([UtlsFingerprintSchema, z.literal('')]);
export type TlsFingerprint = z.infer<typeof TlsFingerprintSchema>;
export const AlpnSchema = z.enum(['h3', 'h2', 'http/1.1']);
export type Alpn = z.infer<typeof AlpnSchema>;
@@ -51,7 +54,7 @@ export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
export type TlsCert = z.infer<typeof TlsCertSchema>;
export const TlsClientSettingsSchema = z.object({
fingerprint: UtlsFingerprintSchema.default('chrome'),
fingerprint: TlsFingerprintSchema.default('chrome'),
echConfigList: z.string().default(''),
pinnedPeerCertSha256: z.array(z.string()).default([]),
});

View File

@@ -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<string, unknown>).fingerprint).toBe('');
expect(tls.certificates).toEqual([
expect.objectContaining({
useFile: true,
certificateFile: '',
keyFile: '',
}),
]);
});
});

View File

@@ -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<string, unknown>;
const settings = tls.settings as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const tls = stream.tlsSettings as Record<string, unknown>;
const settings = tls.settings as Record<string, unknown>;
expect(settings).not.toHaveProperty('fingerprint');
});
});
describe('freedom outbound sockopt wire payload', () => {