From 07ee638a50e2f87785c80b091bbe212e51e9fdd5 Mon Sep 17 00:00:00 2001 From: libmur-dev Date: Sat, 26 Sep 2026 21:31:42 +0300 Subject: [PATCH] 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. --- internal/sub/clash_service.go | 46 +++++++++++++++++++ internal/sub/clash_service_test.go | 74 ++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/internal/sub/clash_service.go b/internal/sub/clash_service.go index c6916a39e..3e361f590 100644 --- a/internal/sub/clash_service.go +++ b/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 diff --git a/internal/sub/clash_service_test.go b/internal/sub/clash_service_test.go index 52e7a06c8..a8b025712 100644 --- a/internal/sub/clash_service_test.go +++ b/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) + } + }) + } +}