Bläddra i källkod

fix(sub): emit Hysteria certificate pin for Mihomo (#6651)

buildHysteriaProxy dropped pinnedPeerCertSha256 from Clash/Mihomo YAML although the raw share link already carries it as pinSHA256, so Mihomo rejected a self-signed Hysteria2 certificate whenever allowInsecure was off.

Emit the first valid SHA-256 pin as Mihomo's fingerprint field in its colon-separated form, honouring an external endpoint's override. client-fingerprint stays the uTLS setting. Mihomo accepts a single fingerprint, so of several pins the first valid one wins.

Refs #4683.
libmur-dev 9 timmar sedan
förälder
incheckning
07ee638a50
2 ändrade filer med 120 tillägg och 0 borttagningar
  1. 46 0
      internal/sub/clash_service.go
  2. 74 0
      internal/sub/clash_service_test.go

+ 46 - 0
internal/sub/clash_service.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"encoding/hex"
 	"errors"
 	"fmt"
 	"maps"
@@ -521,11 +522,17 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 			if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
 				proxy["client-fingerprint"] = fp
 			}
+			if certFingerprint := mihomoCertFingerprint(inner["pinnedPeerCertSha256"]); certFingerprint != "" {
+				proxy["fingerprint"] = certFingerprint
+			}
 		}
 	}
 	if insecure, ok := ep["allowInsecure"].(bool); ok && insecure {
 		proxy["skip-cert-verify"] = true
 	}
+	if certFingerprint := mihomoCertFingerprint(ep["pinnedPeerCertSha256"]); certFingerprint != "" {
+		proxy["fingerprint"] = certFingerprint
+	}
 
 	// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
 	// block the subscription link generator uses.
@@ -555,6 +562,45 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
 	return proxy
 }
 
+// Mihomo supports only one certificate fingerprint, so mihomoCertFingerprint
+// converts the first valid SHA-256 pin to its colon-separated TLS form.
+func mihomoCertFingerprint(value any) string {
+	var pins []string
+	switch typed := value.(type) {
+	case []any:
+		for _, item := range typed {
+			if pin, ok := item.(string); ok {
+				pins = append(pins, pin)
+			}
+		}
+	case []string:
+		pins = typed
+	case string:
+		pins = strings.Split(typed, ",")
+	}
+
+	for _, pin := range pins {
+		normalized := hysteriaPinHex(pin)
+		if len(normalized) != 64 {
+			continue
+		}
+		if _, err := hex.DecodeString(normalized); err != nil {
+			continue
+		}
+		normalized = strings.ToUpper(normalized)
+		var out strings.Builder
+		out.Grow(95)
+		for i := 0; i < len(normalized); i += 2 {
+			if i > 0 {
+				out.WriteByte(':')
+			}
+			out.WriteString(normalized[i : i+2])
+		}
+		return out.String()
+	}
+	return ""
+}
+
 // buildWireguardProxy produces a mihomo-compatible Clash entry for a native
 // WireGuard inbound, mirroring genWireguardLink: the peer public key is derived
 // from the inbound secretKey, while the private key, tunnel address, and

+ 74 - 0
internal/sub/clash_service_test.go

@@ -1431,3 +1431,77 @@ func TestBuildAmneziaWGProxyForClashEffectiveMTU(t *testing.T) {
 		}
 	})
 }
+
+func TestBuildHysteriaProxyIncludesCertificateFingerprint(t *testing.T) {
+	const pin = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+	const want = "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F"
+	const externalPin = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
+	const wantExternal = "FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00"
+
+	svc := &SubClashService{}
+	subReq := &SubService{}
+	inbound := &model.Inbound{
+		Protocol: model.Hysteria,
+		Listen:   "192.0.2.1",
+		Port:     443,
+		Remark:   "hysteria 2",
+		Settings: `{"version":2}`,
+		StreamSettings: `{
+			"tlsSettings": {
+				"alpn": ["h3"],
+				"settings": {
+					"fingerprint": "chrome",
+					"pinnedPeerCertSha256": ["` + pin + `"]
+				}
+			}
+		}`,
+	}
+	client := model.Client{Email: "client", Auth: "secret", Enable: true}
+
+	proxy := svc.buildHysteriaProxy(subReq, inbound, client, nil)
+	if got := proxy["fingerprint"]; got != want {
+		t.Fatalf("fingerprint = %v, want %s", got, want)
+	}
+	if got := proxy["client-fingerprint"]; got != "chrome" {
+		t.Fatalf("client-fingerprint = %v, want chrome", got)
+	}
+
+	externalProxy := svc.buildHysteriaProxy(subReq, inbound, client, map[string]any{
+		"pinnedPeerCertSha256": []any{externalPin},
+	})
+	if got := externalProxy["fingerprint"]; got != wantExternal {
+		t.Fatalf("external fingerprint = %v, want %s", got, wantExternal)
+	}
+}
+
+func TestMihomoCertFingerprintUsesFirstValidPin(t *testing.T) {
+	const firstPin = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
+	const secondPin = "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
+	const wantFirst = "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F"
+	const wantSecond = "FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00"
+
+	tests := []struct {
+		name string
+		pins any
+		want string
+	}{
+		{
+			name: "invalid first pin uses second",
+			pins: []any{"not-a-certificate-pin", secondPin},
+			want: wantSecond,
+		},
+		{
+			name: "two valid pins use first",
+			pins: []any{firstPin, secondPin},
+			want: wantFirst,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := mihomoCertFingerprint(tt.pins); got != tt.want {
+				t.Fatalf("mihomoCertFingerprint() = %q, want %q", got, tt.want)
+			}
+		})
+	}
+}