mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-23 19:37:13 +00:00
fix(outbound): import Hysteria2 salamander properly from standard obfs params (#6166)
* fix(outbound): import Hysteria2 salamander from standard obfs params The outbound share-link importers only reconstructed salamander from the private fm=<json> finalmask dump. Every standard Hysteria2 link — and this panel's own generator (internal/sub) since it stopped emitting fm= — carries the obfuscation as the standard obfs=salamander & obfs-password=<pw> pair, which the importers ignored. As a result, importing a normal Hysteria2 link (pasted into the outbound form or pulled from a subscription) silently dropped the salamander config and produced an outbound that negotiates plain QUIC against a server expecting obfuscation. Parse the standard obfs/obfs-password pair in both the Go importer (internal/util/link, used by subscription + JSON import) and the frontend form parser (outbound-link-parser.ts), folding it into finalmask.udp. A salamander mask already supplied via fm= still wins, so 3x-ui→3x-ui links are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(outbound): address review — mport hop, password-less fm mask, tests Follow-up to the automated PR review on #6166: - Import the Hysteria2 UDP port-hopping range from the standard `mport` param (finalmask.quicParams.udpHop.ports) in both importers — the same class of gap as salamander: the subscription generator emits `mport` standalone and no `fm=`, so port hopping was silently lost on import. An `fm=`-supplied udpHop still wins. - When `fm=` carries a salamander mask without a usable password, fill it in from the obfs pair instead of treating the empty mask as authoritative (would otherwise enable obfuscation with an empty password). - Trim the duplicated rationale comments to two lines each. - Tests: collapse the four per-case Go functions into table-driven subtests; cover the obfs_password/obfsPassword aliases, case-insensitive obfs value, append-onto-non-salamander-udp, password-less-fm fill, and the mport paths; assert the fm-wins masks stay length 1 in both suites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -226,6 +226,47 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFinalMask(stream: Raw): Raw {
|
||||
if (!stream.finalmask || typeof stream.finalmask !== 'object') stream.finalmask = {};
|
||||
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.
|
||||
function applyHysteria2Obfs(stream: Raw, params: URLSearchParams): void {
|
||||
if ((params.get('obfs') ?? '').toLowerCase() !== 'salamander') return;
|
||||
const password = firstParam(params, 'obfs-password', 'obfs_password', 'obfsPassword');
|
||||
if (!password) return;
|
||||
const finalmask = ensureFinalMask(stream);
|
||||
const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
|
||||
const existing = udp.find((m) => m && typeof m === 'object' && (m as Raw).type === 'salamander') as Raw | undefined;
|
||||
if (existing) {
|
||||
const settings = (existing.settings && typeof existing.settings === 'object'
|
||||
? existing.settings
|
||||
: (existing.settings = {})) as Raw;
|
||||
if (typeof settings.password !== 'string' || settings.password.length === 0) settings.password = password;
|
||||
return;
|
||||
}
|
||||
finalmask.udp = [...udp, { type: 'salamander', settings: { password } }];
|
||||
}
|
||||
|
||||
// Rebuild the UDP port-hopping range from the standard mport param, which the
|
||||
// generator emits as finalmask.quicParams.udpHop.ports. A range already supplied
|
||||
// via fm= wins; the client-side interval falls back to the panel's default.
|
||||
function applyHysteria2Hop(stream: Raw, params: URLSearchParams): void {
|
||||
const ports = firstParam(params, 'mport');
|
||||
if (!ports) return;
|
||||
const finalmask = ensureFinalMask(stream);
|
||||
const quicParams = (finalmask.quicParams && typeof finalmask.quicParams === 'object'
|
||||
? finalmask.quicParams
|
||||
: (finalmask.quicParams = {})) as Raw;
|
||||
const existingHop = quicParams.udpHop as Raw | undefined;
|
||||
if (existingHop && typeof existingHop.ports === 'string' && existingHop.ports.length > 0) return;
|
||||
quicParams.udpHop = { ports, interval: '5-10' };
|
||||
}
|
||||
|
||||
const QUIC_PARAMS_NUMERIC_KEYS = [
|
||||
'initStreamReceiveWindow',
|
||||
'maxStreamReceiveWindow',
|
||||
@@ -525,6 +566,8 @@ export function parseHysteria2Link(link: string): Raw | null {
|
||||
},
|
||||
};
|
||||
applyFinalMaskParam(stream, params);
|
||||
applyHysteria2Obfs(stream, params);
|
||||
applyHysteria2Hop(stream, params);
|
||||
return {
|
||||
protocol: 'hysteria',
|
||||
tag: decodeRemark(url),
|
||||
|
||||
Reference in New Issue
Block a user