mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-04 15:17:27 +00:00
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:
parent
264f61eb90
commit
9e43e16096
4 changed files with 162 additions and 0 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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' } }],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue