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:
MMX
2026-08-14 17:43:29 +03:00
committed by GitHub
parent 9165ab67eb
commit d05e44e401
4 changed files with 362 additions and 0 deletions
@@ -300,10 +300,97 @@ describe('parseHysteria2Link', () => {
const finalmask = stream.finalmask as Record<string, unknown>;
expect(finalmask).toBeDefined();
const udp = finalmask.udp as Array<Record<string, unknown>>;
expect(udp).toHaveLength(1);
expect(udp[0].type).toBe('salamander');
expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
});
it('reconstructs the salamander mask from standard obfs= without fm=', () => {
const link = 'hysteria2://auth@news.domain.org:8443?security=tls&sni=news.domain.org'
+ '&obfs=salamander&obfs-password=ftwfgb9655hh2mgo#hy2-std-obfs';
const out = parseHysteria2Link(link);
expect(out).not.toBeNull();
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
expect(finalmask).toBeDefined();
const udp = finalmask.udp as Array<Record<string, unknown>>;
expect(udp).toHaveLength(1);
expect(udp[0].type).toBe('salamander');
expect((udp[0].settings as Record<string, unknown>).password).toBe('ftwfgb9655hh2mgo');
});
it('adds no salamander mask when the link carries neither obfs nor fm', () => {
const out = parseHysteria2Link('hysteria2://auth@srv:443?security=tls&sni=srv#hy2-plain');
expect(out).not.toBeNull();
expect((out!.streamSettings as Record<string, unknown>).finalmask).toBeUndefined();
});
it('ignores obfs=salamander when no obfs-password is present', () => {
const out = parseHysteria2Link('hysteria2://auth@srv:443?security=tls&obfs=salamander#hy2-nopw');
expect(out).not.toBeNull();
expect((out!.streamSettings as Record<string, unknown>).finalmask).toBeUndefined();
});
it.each([
['obfs_password', 'obfs_password=aliaspw', 'aliaspw'],
['obfsPassword', 'obfsPassword=camelpw', 'camelpw'],
['case-insensitive type', 'obfs=Salamander&obfs-password=mixed', 'mixed'],
])('accepts the %s form of the obfs pair', (_name, query, want) => {
const base = query.includes('obfs=') ? query : `obfs=salamander&${query}`;
const out = parseHysteria2Link(`hysteria2://auth@srv:443?security=tls&${base}#hy2-alias`);
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
const udp = finalmask.udp as Array<Record<string, unknown>>;
expect(udp).toHaveLength(1);
expect(udp[0].type).toBe('salamander');
expect((udp[0].settings as Record<string, unknown>).password).toBe(want);
});
it('appends the obfs salamander mask alongside a non-salamander fm mask', () => {
const fm = encodeURIComponent(JSON.stringify({
udp: [{ type: 'mkcp-legacy', settings: { header: 'srtp' } }],
}));
const link = `hysteria2://auth@srv:443?security=tls&fm=${fm}&obfs=salamander&obfs-password=added#hy2-append`;
const out = parseHysteria2Link(link);
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
const udp = finalmask.udp as Array<Record<string, unknown>>;
expect(udp).toHaveLength(2);
expect(udp[0].type).toBe('mkcp-legacy');
expect(udp[1].type).toBe('salamander');
expect((udp[1].settings as Record<string, unknown>).password).toBe('added');
});
it('fills the password of a password-less fm salamander mask from obfs', () => {
const fm = encodeURIComponent(JSON.stringify({
udp: [{ type: 'salamander', settings: {} }],
}));
const link = `hysteria2://auth@srv:443?security=tls&fm=${fm}&obfs=salamander&obfs-password=fromobfs#hy2-fill`;
const out = parseHysteria2Link(link);
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
const udp = finalmask.udp as Array<Record<string, unknown>>;
expect(udp).toHaveLength(1);
expect((udp[0].settings as Record<string, unknown>).password).toBe('fromobfs');
});
it('reconstructs udpHop from the standard mport param', () => {
const out = parseHysteria2Link('hysteria2://auth@srv:443?security=tls&mport=20000-50000#hy2-mport');
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
const quic = finalmask.quicParams as Record<string, unknown>;
const udpHop = quic.udpHop as Record<string, unknown>;
expect(udpHop.ports).toBe('20000-50000');
expect(udpHop.interval).toBe('5-10');
});
it('lets an fm= udpHop win over mport', () => {
const fm = encodeURIComponent(JSON.stringify({
quicParams: { udpHop: { ports: '30000-40000', interval: '7-9' } },
}));
const link = `hysteria2://auth@srv:443?security=tls&mport=1-2&fm=${fm}#hy2-mport-fm`;
const out = parseHysteria2Link(link);
const finalmask = (out!.streamSettings as Record<string, unknown>).finalmask as Record<string, unknown>;
const udpHop = (finalmask.quicParams as Record<string, unknown>).udpHop as Record<string, unknown>;
expect(udpHop.ports).toBe('30000-40000');
expect(udpHop.interval).toBe('7-9');
});
it('round-trips the salamander packetSize (Gecko) under fm', () => {
const fm = encodeURIComponent(JSON.stringify({
udp: [{ type: 'salamander', settings: { password: 'ftwfgb9655hh2mgo', packetSize: '100-200' } }],