Просмотр исходного кода

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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
Co-authored-by: Sanaei <[email protected]>
MMX 5 часов назад
Родитель
Сommit
d05e44e401

+ 43 - 0
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=<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),

+ 87 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -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://[email protected]: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' } }],

+ 65 - 0
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=<pw> 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.
+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

+ 167 - 0
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://[email protected]: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://[email protected]: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://[email protected]: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:[email protected]:8388"))