fix(sub): export Hysteria2 Gecko obfs in Clash/Mihomo subscriptions

A salamander finalmask carrying a packetSize range is Gecko obfs. The
hysteria2:// link generators already export it, but buildHysteriaProxy
copied only the password, so Clash/Mihomo received plain salamander and
could never complete the handshake against a Gecko server.

Emit mihomo's gecko form instead: obfs: gecko plus integer
obfs-min-packet-size / obfs-max-packet-size, validated by the same
parseHysteriaPacketSize bounds the URI export uses. A password-only mask
keeps the plain salamander output.

Closes #6822
This commit is contained in:
MHSanaei
2026-10-14 14:42:00 +02:00
parent db8b01b8ed
commit cb4f2ec92f
2 changed files with 50 additions and 2 deletions
+10 -2
View File
@@ -7,6 +7,7 @@ import (
"maps"
"net/netip"
"slices"
"strconv"
"strings"
"github.com/goccy/go-json"
@@ -540,8 +541,8 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
proxy["fingerprint"] = certFingerprint
}
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
// block the subscription link generator uses.
// Salamander obfs (Hysteria2), read from the same finalmask.udp[salamander]
// block as the link generator; a packetSize range is mihomo's gecko obfs.
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
if udpMasks, ok := finalmask["udp"].([]any); ok {
for _, m := range udpMasks {
@@ -553,6 +554,13 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
if pw, ok := settings["password"].(string); ok && pw != "" {
proxy["obfs"] = "salamander"
proxy["obfs-password"] = pw
packetSize, _ := settings["packetSize"].(string)
if gecko := parseHysteriaPacketSize(packetSize); gecko != "" {
minStr, maxStr := splitHysteriaPacketSize(gecko)
proxy["obfs"] = "gecko"
proxy["obfs-min-packet-size"], _ = strconv.Atoi(minStr)
proxy["obfs-max-packet-size"], _ = strconv.Atoi(maxStr)
}
break
}
}
+40
View File
@@ -150,3 +150,43 @@ func TestParseHysteriaPacketSizeBounds(t *testing.T) {
t.Fatalf("valid range = %q", got)
}
}
// Mihomo reads Gecko only from obfs: gecko plus integer packet-size keys; plain
// salamander on a Gecko server never completes the handshake.
func TestBuildHysteriaProxyExportsGeckoObfs(t *testing.T) {
cases := []struct {
name string
settings string
want map[string]any
}{
{
name: "packetSize exports gecko",
settings: `{"password":"pw","packetSize":"512-1200"}`,
want: map[string]any{
"obfs": "gecko", "obfs-password": "pw",
"obfs-min-packet-size": 512, "obfs-max-packet-size": 1200,
},
},
{
name: "password only stays salamander",
settings: `{"password":"pw"}`,
want: map[string]any{"obfs": "salamander", "obfs-password": "pw"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
in := &model.Inbound{
Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
Settings: `{"version":2}`,
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` +
tc.settings + `}]}}`,
}
proxy := (&SubClashService{}).buildHysteriaProxy(&SubService{}, in, model.Client{Email: "user", Auth: "secret"}, nil)
for _, key := range []string{"obfs", "obfs-password", "obfs-min-packet-size", "obfs-max-packet-size"} {
if got, want := proxy[key], tc.want[key]; got != want {
t.Errorf("%s = %#v, want %#v", key, got, want)
}
}
})
}
}