mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-23 03:17:14 +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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user