fix(hysteria): standard geco share links and persistent uTLS None (#6325)

- Export standard gecko obfs query params in hysteria2 share links
- Enforce packet size bounds across Go and TypeScript link handlers
- Persist uTLS None explicitly and initialize new TLS inbounds to chrome
- Tear down stackTun safely without closeMu deadlock against WriteNotify

Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com>
This commit is contained in:
Rouzbeh†
2026-09-03 18:05:07 +03:30
committed by GitHub
parent f9898e0b24
commit 540caa4e93
14 changed files with 514 additions and 111 deletions
+13 -5
View File
@@ -12,6 +12,7 @@ import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-pro
import type { FinalMaskStreamSettings } from '@/schemas/protocols/stream/finalmask';
import type { XHttpStreamSettings } from '@/schemas/protocols/stream/xhttp';
import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
import { getHeaderValue } from './headers';
import { canEnableTlsFlow } from './protocol-capabilities';
import { deriveSpiderX } from './spider-x';
@@ -437,7 +438,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
params.set('security', 'tls');
if (stream.security === 'tls') {
const tls = stream.tlsSettings;
params.set('fp', tls.settings.fingerprint);
if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
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);
@@ -543,7 +544,7 @@ function writeTlsParams(
): void {
if (stream.security !== 'tls') return;
const tls = stream.tlsSettings;
params.set('fp', tls.settings.fingerprint);
if (tls.settings.fingerprint.length > 0) params.set('fp', tls.settings.fingerprint);
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);
@@ -801,13 +802,20 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
const salamander = udpMasks.find((m) => m?.type === 'salamander');
const obfsPassword = salamander?.settings?.password;
if (typeof obfsPassword === 'string' && obfsPassword.length > 0) {
params.set('obfs', 'salamander');
// packetSize (Gecko mode) exports via v2rayN's native fields; the
// experimental fm=<json> dump breaks mihomo and other strict clients.
const range = parseGeckoPacketSize(salamander?.settings?.packetSize);
if (range) {
params.set('obfs', 'gecko');
params.set('minPacketSize', String(range.min));
params.set('maxPacketSize', String(range.max));
} else {
params.set('obfs', 'salamander');
}
params.set('obfs-password', obfsPassword);
}
}
applyFinalMaskToParams(stream.finalmask, params);
const hopPorts = stream.finalmask?.quicParams?.udpHop?.ports?.trim() ?? '';
if (hopPorts.length > 0) {
params.set('mport', hopPorts);
@@ -17,6 +17,12 @@ function defaultCertificate(): Record<string, unknown> {
export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
tls.certificates = [defaultCertificate()];
const settings =
tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
? { ...(tls.settings as Record<string, unknown>) }
: {};
settings.fingerprint = 'chrome';
tls.settings = settings;
return tls;
}
+33 -6
View File
@@ -258,14 +258,34 @@ function ensureFinalMask(stream: Raw): Raw {
return stream.finalmask as Raw;
}
// Rebuild the salamander mask from the standard Hysteria2 obfs pair (every
// non-3x-ui client, and this panel's own generator, speak it instead of the
// private fm=<json> dump). A salamander mask already carrying a password via fm=
// wins; a password-less one is completed rather than left empty.
// Rebuild the salamander mask from the standard Hysteria2 obfs pair; an fm=
// password wins. obfs=gecko adds min/maxPacketSize stored as packetSize.
function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
if ((params.get('obfs') ?? '').toLowerCase() !== 'salamander') return;
const obfs = (params.get('obfs') ?? '').toLowerCase();
const isGecko = obfs === 'gecko';
if (!isGecko && obfs !== 'salamander') return;
const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
if (!password) return;
let packetSize = '';
if (isGecko) {
// Both halves required and numeric, matching the export side; anything
// else is dropped rather than stored as a malformed range.
const minSize = (params.get('minPacketSize') ?? '').trim();
const maxSize = (params.get('maxPacketSize') ?? '').trim();
const min = Number(minSize);
const max = Number(maxSize);
if (
/^\d+$/.test(minSize) &&
/^\d+$/.test(maxSize) &&
Number.isSafeInteger(min) &&
Number.isSafeInteger(max) &&
min >= 1 &&
max >= min &&
max <= 2048
) {
packetSize = `${min}-${max}`;
}
}
const finalmask = ensureFinalMask(stream);
const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
const existing = udp.find(
@@ -279,9 +299,16 @@ function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
) as Raw;
if (typeof settings.password !== 'string' || settings.password.length === 0)
settings.password = password;
if (
packetSize !== '' &&
!(typeof settings.packetSize === 'string' && settings.packetSize.length > 0)
)
settings.packetSize = packetSize;
return;
}
finalmask.udp = [...udp, { type: 'salamander', settings: { password } }];
const settings: Raw = { password };
if (packetSize !== '') settings.packetSize = packetSize;
finalmask.udp = [...udp, { type: 'salamander', settings }];
}
// Rebuild the UDP port-hopping range from the standard mport param, which the
+6 -2
View File
@@ -130,8 +130,12 @@ export default function HostFormModal({
[],
);
const fpOptions = useMemo(
() => Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })),
[],
// '' = None first: Hysteria (and any no-uTLS host) must be selectable.
() => [
{ value: '', label: t('none') },
...Object.values(UTLS_FINGERPRINT).map((v) => ({ value: v, label: v })),
],
[t],
);
const hostOptions = useMemo(() => {
@@ -56,7 +56,9 @@ export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
export type TlsCert = z.infer<typeof TlsCertSchema>;
export const TlsClientSettingsSchema = z.object({
fingerprint: TlsFingerprintSchema.default('chrome'),
// '' = None. Hysteria rejects uTLS fingerprints, and a chrome default
// silently flipped the form's None back to chrome on every save.
fingerprint: TlsFingerprintSchema.default(''),
echConfigList: z.string().default(''),
pinnedPeerCertSha256: z.array(z.string()).default([]),
// Panel-only client directive (v2rayN `vcn`): verify the server certificate
@@ -87,7 +89,7 @@ export const TlsStreamSettingsSchema = z.object({
masterKeyLog: z.string().optional(),
echSockopt: SockoptStreamSettingsSchema.optional(),
settings: TlsClientSettingsSchema.default({
fingerprint: 'chrome',
fingerprint: '',
echConfigList: '',
pinnedPeerCertSha256: [],
verifyPeerCertByName: '',
@@ -808,3 +808,35 @@ describe('parseOutboundLink dispatcher', () => {
expect(parseOutboundLink(' ')).toBeNull();
});
});
describe('obfs=gecko packetSize validation', () => {
const base = 'hysteria2://secret@1.2.3.4:443?security=tls&obfs=gecko&obfs-password=pw';
const packetSizeOf = (link: string): string | undefined => {
const out = parseHysteria2Link(link);
expect(out).not.toBeNull();
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as
| Record<string, unknown>
| undefined;
const udp = (finalmask?.udp ?? []) as Array<Record<string, unknown>>;
const mask = udp.find((m) => m.type === 'salamander');
return (mask?.settings as Record<string, unknown> | undefined)?.packetSize as
| string
| undefined;
};
it('stores a valid range', () => {
expect(packetSizeOf(`${base}&minPacketSize=512&maxPacketSize=1200`)).toBe('512-1200');
});
it.each([
['min only', `${base}&minPacketSize=512`],
['max only', `${base}&maxPacketSize=1200`],
['non-numeric', `${base}&minPacketSize=abc&maxPacketSize=def`],
['zero min', `${base}&minPacketSize=0&maxPacketSize=1200`],
['inverted', `${base}&minPacketSize=1200&maxPacketSize=512`],
['over cap', `${base}&minPacketSize=512&maxPacketSize=4096`],
])('drops the %s range', (_name, link) => {
expect(packetSizeOf(link)).toBeUndefined();
});
});
@@ -0,0 +1,77 @@
/// <reference types="vite/client" />
import { describe, expect, it } from 'vitest';
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
import {
createTlsSettingsWithDefaultCert,
createHysteriaTlsSettingsWithDefaultCert,
} from '@/lib/xray/inbound-tls-defaults';
import { genHysteriaLink } from '@/lib/xray/inbound-link';
import type { Inbound } from '@/schemas/api/inbound';
// uTLS None ('') must survive a schema parse; the old default flipped it to
// chrome on every save.
describe('TlsClientSettingsSchema fingerprint default', () => {
it('parses an omitted fingerprint as None, not chrome', () => {
const parsed = TlsStreamSettingsSchema.parse({});
expect(parsed.settings.fingerprint).toBe('');
});
it('keeps an explicit empty-string fingerprint through parse', () => {
const parsed = TlsStreamSettingsSchema.parse({
settings: {
fingerprint: '',
echConfigList: '',
pinnedPeerCertSha256: [],
verifyPeerCertByName: '',
},
});
expect(parsed.settings.fingerprint).toBe('');
});
it('initializes generic TLS inbounds with chrome fingerprint default', () => {
const tls = createTlsSettingsWithDefaultCert();
expect((tls.settings as Record<string, unknown>)?.fingerprint).toBe('chrome');
});
it('initializes hysteria TLS inbounds with empty fingerprint default', () => {
const tls = createHysteriaTlsSettingsWithDefaultCert();
expect((tls.settings as Record<string, unknown>)?.fingerprint).toBe('');
});
it('does not inject fp into the hysteria share link when fingerprint is None', () => {
const raw = {
id: 1,
port: 443,
protocol: 'hysteria',
settings: { version: 2, clients: [{ auth: 'secret' }] },
streamSettings: {
security: 'tls',
tlsSettings: {
serverName: 'hy.test',
alpn: ['h3'],
settings: {
fingerprint: '',
echConfigList: '',
pinnedPeerCertSha256: [],
verifyPeerCertByName: '',
},
},
finalmask: {
udp: [{ type: 'salamander', settings: { password: 'pw', packetSize: '512-1200' } }],
},
},
};
const link = genHysteriaLink({
inbound: raw as unknown as Inbound,
address: 'example.test',
remark: 'gecko',
clientAuth: 'secret',
});
expect(link).toContain('obfs=gecko');
expect(link).toContain('minPacketSize=512');
expect(link).toContain('maxPacketSize=1200');
expect(link).not.toContain('fp=');
expect(link).not.toContain('fm=');
});
});