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