mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 18:11:00 +00:00
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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user