diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index b6c2014a8..4629b68a2 100644 --- a/internal/sub/clash_service.go +++ b/internal/sub/clash_service.go @@ -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 } } diff --git a/internal/sub/hysteria_geco_uri_test.go b/internal/sub/hysteria_geco_uri_test.go index d69aecd60..b7ec1574f 100644 --- a/internal/sub/hysteria_geco_uri_test.go +++ b/internal/sub/hysteria_geco_uri_test.go @@ -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) + } + } + }) + } +}