mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-15 09:35:00 +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
|
package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
@@ -521,11 +522,17 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
|
|||||||
if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
|
if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
|
||||||
proxy["client-fingerprint"] = fp
|
proxy["client-fingerprint"] = fp
|
||||||
}
|
}
|
||||||
|
if certFingerprint := mihomoCertFingerprint(inner["pinnedPeerCertSha256"]); certFingerprint != "" {
|
||||||
|
proxy["fingerprint"] = certFingerprint
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if insecure, ok := ep["allowInsecure"].(bool); ok && insecure {
|
if insecure, ok := ep["allowInsecure"].(bool); ok && insecure {
|
||||||
proxy["skip-cert-verify"] = true
|
proxy["skip-cert-verify"] = true
|
||||||
}
|
}
|
||||||
|
if certFingerprint := mihomoCertFingerprint(ep["pinnedPeerCertSha256"]); certFingerprint != "" {
|
||||||
|
proxy["fingerprint"] = certFingerprint
|
||||||
|
}
|
||||||
|
|
||||||
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
|
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
|
||||||
// block the subscription link generator uses.
|
// block the subscription link generator uses.
|
||||||
@@ -555,6 +562,45 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
|
|||||||
return proxy
|
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
|
// buildWireguardProxy produces a mihomo-compatible Clash entry for a native
|
||||||
// WireGuard inbound, mirroring genWireguardLink: the peer public key is derived
|
// WireGuard inbound, mirroring genWireguardLink: the peer public key is derived
|
||||||
// from the inbound secretKey, while the private key, tunnel address, and
|
// 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