diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 644efe39d..63b1b873f 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -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= 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), diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 44f993527..ea18e5af0 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -300,10 +300,97 @@ describe('parseHysteria2Link', () => { const finalmask = stream.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('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.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).finalmask as Record; + const udp = finalmask.udp as Array>; + expect(udp).toHaveLength(1); + expect(udp[0].type).toBe('salamander'); + expect((udp[0].settings as Record).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).finalmask as Record; + const udp = finalmask.udp as Array>; + expect(udp).toHaveLength(2); + expect(udp[0].type).toBe('mkcp-legacy'); + expect(udp[1].type).toBe('salamander'); + expect((udp[1].settings as Record).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).finalmask as Record; + const udp = finalmask.udp as Array>; + expect(udp).toHaveLength(1); + expect((udp[0].settings as Record).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).finalmask as Record; + const quic = finalmask.quicParams as Record; + const udpHop = quic.udpHop as Record; + 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).finalmask as Record; + const udpHop = (finalmask.quicParams as Record).udpHop as Record; + 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' } }], diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 66c01c425..206967d21 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -457,6 +457,8 @@ func parseHysteria2(link string) (*ParseResult, error) { }, } applyFinalMask(stream, params) + applyHysteria2Obfs(stream, params) + applyHysteria2Hop(stream, params) identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params) @@ -686,6 +688,69 @@ func applyFinalMask(stream map[string]any, p url.Values) { } } +// applyHysteria2Obfs rebuilds the salamander mask from the standard Hysteria2 +// obfs=salamander & obfs-password= pair (every non-3x-ui client, and this +// panel's own generator, speak it instead of the private fm= dump). A +// salamander mask already carrying a password via fm= wins; a password-less one +// is completed rather than left empty. +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 := ensureChildMap(stream, "finalmask") + udp, _ := finalmask["udp"].([]any) + for _, m := range udp { + mask, ok := m.(map[string]any) + if !ok || mask["type"] != "salamander" { + continue + } + settings, ok := mask["settings"].(map[string]any) + if !ok { + settings = map[string]any{} + mask["settings"] = settings + } + if pw, _ := settings["password"].(string); pw == "" { + settings["password"] = password + } + return + } + finalmask["udp"] = append(udp, map[string]any{ + "type": "salamander", + "settings": map[string]any{"password": password}, + }) +} + +// applyHysteria2Hop rebuilds 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 same +// default the panel writes. +func applyHysteria2Hop(stream map[string]any, p url.Values) { + ports := firstParam(p, "mport") + if ports == "" { + return + } + quicParams := ensureChildMap(ensureChildMap(stream, "finalmask"), "quicParams") + if udpHop, ok := quicParams["udpHop"].(map[string]any); ok { + if existing, _ := udpHop["ports"].(string); existing != "" { + return + } + } + quicParams["udpHop"] = map[string]any{"ports": ports, "interval": "5-10"} +} + +func ensureChildMap(parent map[string]any, key string) map[string]any { + m, ok := parent[key].(map[string]any) + if !ok { + m = map[string]any{} + parent[key] = m + } + return m +} + // 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..eabfc6b2e 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -114,6 +114,173 @@ 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 finalmaskUDP(t *testing.T, res *ParseResult) []any { + t.Helper() + stream, _ := res.Outbound["streamSettings"].(map[string]any) + finalmask, _ := stream["finalmask"].(map[string]any) + udp, _ := finalmask["udp"].([]any) + return udp +} + +func hopPorts(t *testing.T, res *ParseResult) (string, bool) { + t.Helper() + stream, _ := res.Outbound["streamSettings"].(map[string]any) + finalmask, _ := stream["finalmask"].(map[string]any) + quicParams, _ := finalmask["quicParams"].(map[string]any) + udpHop, ok := quicParams["udpHop"].(map[string]any) + if !ok { + return "", false + } + ports, _ := udpHop["ports"].(string) + return ports, true +} + +func TestParseHysteria2_Obfs(t *testing.T) { + cases := []struct { + name string + query string + wantPw string + wantSet bool + }{ + {"standard", "obfs=salamander&obfs-password=s3cr3t", "s3cr3t", true}, + {"snake-case alias", "obfs=salamander&obfs_password=aliaspw", "aliaspw", true}, + {"camel-case alias", "obfs=salamander&obfsPassword=camelpw", "camelpw", true}, + {"case-insensitive type", "obfs=Salamander&obfs-password=mixed", "mixed", true}, + {"no obfs", "sni=ex.com", "", false}, + {"obfs without password", "obfs=salamander", "", false}, + {"unknown obfs type", "obfs=random&obfs-password=x", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&" + c.query + "#node") + if err != nil { + t.Fatalf("parse hysteria2: %v", err) + } + if res.Outbound["protocol"] != "hysteria" { + t.Fatalf("bad protocol: %v", res.Outbound["protocol"]) + } + pw, ok := salamanderPassword(t, res) + if ok != c.wantSet { + t.Fatalf("salamander mask present = %v, want %v (stream: %v)", ok, c.wantSet, res.Outbound["streamSettings"]) + } + if pw != c.wantPw { + t.Errorf("salamander password: got %q, want %q", pw, c.wantPw) + } + }) + } +} + +func TestParseHysteria2_ObfsFinalMaskPrecedence(t *testing.T) { + cases := []struct { + name string + fm string + obfsPw string + wantPw string + wantUDPLen int + }{ + { + name: "fm password wins over obfs", + fm: `{"udp":[{"type":"salamander","settings":{"password":"fromfm"}}]}`, + obfsPw: "fromobfs", + wantPw: "fromfm", + wantUDPLen: 1, + }, + { + name: "obfs fills password-less fm mask", + fm: `{"udp":[{"type":"salamander","settings":{}}]}`, + obfsPw: "fromobfs", + wantPw: "fromobfs", + wantUDPLen: 1, + }, + { + name: "obfs appends alongside a non-salamander mask", + fm: `{"udp":[{"type":"mkcp-legacy","settings":{"header":"srtp"}}]}`, + obfsPw: "fromobfs", + wantPw: "fromobfs", + wantUDPLen: 2, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + link := "hysteria2://auth@1.2.3.4:443?security=tls&fm=" + url.QueryEscape(c.fm) + + "&obfs=salamander&obfs-password=" + c.obfsPw + "#node" + res, err := ParseLink(link) + 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 != c.wantPw { + t.Errorf("salamander password: got %q, want %q", pw, c.wantPw) + } + if udp := finalmaskUDP(t, res); len(udp) != c.wantUDPLen { + t.Errorf("udp mask count: got %d, want %d (%v)", len(udp), c.wantUDPLen, udp) + } + }) + } +} + +func TestParseHysteria2_Mport(t *testing.T) { + cases := []struct { + name string + query string + wantPorts string + wantHop bool + }{ + {"standard mport", "mport=20000-50000", "20000-50000", true}, + {"no mport", "sni=ex.com", "", false}, + { + name: "fm udpHop wins over mport", + query: "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`), + wantPorts: "30000-40000", + wantHop: true, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&" + c.query + "#node") + if err != nil { + t.Fatalf("parse hysteria2: %v", err) + } + ports, ok := hopPorts(t, res) + if ok != c.wantHop { + t.Fatalf("udpHop present = %v, want %v (stream: %v)", ok, c.wantHop, res.Outbound["streamSettings"]) + } + if ports != c.wantPorts { + t.Errorf("hop ports: got %q, want %q", ports, c.wantPorts) + } + }) + } +} + 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"))