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