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>
This commit is contained in:
MX 2026-07-31 14:24:51 +03:00
parent 264f61eb90
commit 9e43e16096
No known key found for this signature in database
GPG key ID: 1A5E1E26DA2373B2
4 changed files with 162 additions and 0 deletions

View file

@ -226,6 +226,25 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
}
}
// Reconstruct the salamander finalmask mask from the standard Hysteria2
// obfs=salamander & obfs-password=<pw> URI pair. Panels (including this one)
// now emit those standard fields instead of the private fm=<json> dump, and
// every other Hysteria2 client speaks the same pair — so reading only fm=
// here silently drops salamander and imports an outbound that negotiates plain
// QUIC against a server that expects obfuscation. When fm= already carried a
// salamander mask it wins and this is a no-op.
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 = (stream.finalmask && typeof stream.finalmask === 'object'
? stream.finalmask
: (stream.finalmask = {})) as Record<string, unknown>;
const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as Raw[]) : [];
if (udp.some((m) => m && typeof m === 'object' && (m as Raw).type === 'salamander')) return;
finalmask.udp = [...udp, { type: 'salamander', settings: { password } }];
}
const QUIC_PARAMS_NUMERIC_KEYS = [
'initStreamReceiveWindow',
'maxStreamReceiveWindow',
@ -525,6 +544,7 @@ export function parseHysteria2Link(link: string): Raw | null {
},
};
applyFinalMaskParam(stream, params);
applyHysteria2Obfs(stream, params);
return {
protocol: 'hysteria',
tag: decodeRemark(url),

View file

@ -304,6 +304,31 @@ describe('parseHysteria2Link', () => {
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('round-trips the salamander packetSize (Gecko) under fm', () => {
const fm = encodeURIComponent(JSON.stringify({
udp: [{ type: 'salamander', settings: { password: 'ftwfgb9655hh2mgo', packetSize: '100-200' } }],

View file

@ -457,6 +457,7 @@ func parseHysteria2(link string) (*ParseResult, error) {
},
}
applyFinalMask(stream, params)
applyHysteria2Obfs(stream, params)
identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
@ -686,6 +687,39 @@ func applyFinalMask(stream map[string]any, p url.Values) {
}
}
// applyHysteria2Obfs reconstructs the salamander finalmask mask from the
// standard Hysteria2 obfs=salamander & obfs-password=<pw> URI pair. Panels
// (including this one) now emit those standard fields instead of the private
// fm=<json> dump, and every other Hysteria2 client speaks the same pair, so an
// importer that only reads fm= silently drops salamander and negotiates plain
// QUIC against a server that expects obfuscation. When fm= already supplied a
// salamander mask it wins and this is a no-op; otherwise the pair is folded
// into finalmask.udp so the outbound round-trips.
func applyHysteria2Obfs(stream map[string]any, p url.Values) {
if !strings.EqualFold(p.Get("obfs"), "salamander") {
return
}
password := firstParam(p, "obfs-password", "obfs_password", "obfsPassword")
if password == "" {
return
}
finalmask, ok := stream["finalmask"].(map[string]any)
if !ok {
finalmask = map[string]any{}
stream["finalmask"] = finalmask
}
udp, _ := finalmask["udp"].([]any)
for _, m := range udp {
if mask, ok := m.(map[string]any); ok && mask["type"] == "salamander" {
return
}
}
finalmask["udp"] = append(udp, map[string]any{
"type": "salamander",
"settings": map[string]any{"password": password},
})
}
// sanitizeFinalMaskQuicParams coerces the strictly numeric quicParams fields
// of a finalmask blob taken verbatim from a share link's fm= parameter.
// Xray-core rejects the whole config at startup when e.g. keepAlivePeriod

View file

@ -114,6 +114,89 @@ func TestSanitizeFinalMaskQuicParams_ClampsAndRejects(t *testing.T) {
}
}
func salamanderPassword(t *testing.T, res *ParseResult) (string, bool) {
t.Helper()
stream, ok := res.Outbound["streamSettings"].(map[string]any)
if !ok {
t.Fatalf("missing streamSettings: %v", res.Outbound)
}
finalmask, ok := stream["finalmask"].(map[string]any)
if !ok {
return "", false
}
udp, ok := finalmask["udp"].([]any)
if !ok {
return "", false
}
for _, m := range udp {
mask, _ := m.(map[string]any)
if mask == nil || mask["type"] != "salamander" {
continue
}
settings, _ := mask["settings"].(map[string]any)
pw, _ := settings["password"].(string)
return pw, true
}
return "", false
}
func TestParseHysteria2_StandardObfs(t *testing.T) {
res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&sni=ex.com&obfs=salamander&obfs-password=s3cr3t#node")
if err != nil {
t.Fatalf("parse hysteria2 with obfs: %v", err)
}
if res.Outbound["protocol"] != "hysteria" {
t.Fatalf("bad protocol: %v", res.Outbound["protocol"])
}
pw, ok := salamanderPassword(t, res)
if !ok {
t.Fatalf("salamander mask missing: %v", res.Outbound["streamSettings"])
}
if pw != "s3cr3t" {
t.Errorf("salamander password: expected s3cr3t, got %q", pw)
}
}
func TestParseHysteria2_NoObfs(t *testing.T) {
res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&sni=ex.com#node")
if err != nil {
t.Fatalf("parse hysteria2: %v", err)
}
if _, ok := salamanderPassword(t, res); ok {
t.Errorf("did not expect a salamander mask without obfs")
}
}
func TestParseHysteria2_ObfsWithoutPasswordIgnored(t *testing.T) {
res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&obfs=salamander#node")
if err != nil {
t.Fatalf("parse hysteria2: %v", err)
}
if _, ok := salamanderPassword(t, res); ok {
t.Errorf("obfs without a password should not add a salamander mask")
}
}
func TestParseHysteria2_FinalMaskWinsOverObfs(t *testing.T) {
fm := url.QueryEscape(`{"udp":[{"type":"salamander","settings":{"password":"fromfm"}}]}`)
res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&fm=" + fm + "&obfs=salamander&obfs-password=fromobfs#node")
if err != nil {
t.Fatalf("parse hysteria2: %v", err)
}
pw, ok := salamanderPassword(t, res)
if !ok {
t.Fatalf("salamander mask missing: %v", res.Outbound["streamSettings"])
}
if pw != "fromfm" {
t.Errorf("fm= salamander should win, got %q", pw)
}
stream := res.Outbound["streamSettings"].(map[string]any)
finalmask := stream["finalmask"].(map[string]any)
if udp, _ := finalmask["udp"].([]any); len(udp) != 1 {
t.Errorf("expected a single salamander mask, got %d", len(udp))
}
}
func TestParseShadowsocks(t *testing.T) {
modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:8388"))