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.
This commit is contained in:
libmur-dev
2026-09-26 21:31:42 +03:00
committed by GitHub
parent ee2ff48c81
commit 07ee638a50
2 changed files with 120 additions and 0 deletions
+46
View File
@@ -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
View File
@@ -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)
}
})
}
}