mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-27 09:42:10 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user