diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 644efe39d..be590c001 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -226,6 +226,25 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void { } } +// Reconstruct the salamander finalmask mask from the standard Hysteria2 +// obfs=salamander & obfs-password= URI pair. Panels (including this one) +// now emit those standard fields instead of the private fm= 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; + 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), diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 44f993527..de7f8db52 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -304,6 +304,31 @@ describe('parseHysteria2Link', () => { expect((udp[0].settings as Record).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).finalmask as Record; + expect(finalmask).toBeDefined(); + const udp = finalmask.udp as Array>; + expect(udp).toHaveLength(1); + expect(udp[0].type).toBe('salamander'); + expect((udp[0].settings as Record).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).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).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' } }], diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 66c01c425..6928abba8 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -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= URI pair. Panels +// (including this one) now emit those standard fields instead of the private +// fm= 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 diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index fb01598b5..2af015870 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -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"))