Browse Source

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
MHSanaei 14 hours ago
parent
commit
cb4f2ec92f
2 changed files with 50 additions and 2 deletions
  1. 10 2
      internal/sub/clash_service.go
  2. 40 0
      internal/sub/hysteria_geco_uri_test.go

+ 10 - 2
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
 				}
 			}

+ 40 - 0
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)
+				}
+			}
+		})
+	}
+}