fix(tls): pin remote cert via native uTLS handshake instead of xray subprocess

GetRemoteCertHash shelled out to 'xray tls ping' and scraped its stdout, which swallowed the real failure (a refused dial surfaced only as 'no certificate hash found'). Replace it with a native uTLS Chrome handshake: dial/handshake errors now surface verbatim, host:port is honoured, and the leaf is taken from PeerCertificates[0] so IP-only self-signed certs (no DNS SANs) hash correctly. Mirrors alireza0/x-ui@1372ad0 without its nil-leaf panic.
This commit is contained in:
MHSanaei
2026-06-21 19:51:18 +02:00
parent 03e89683dd
commit 0483273839
2 changed files with 37 additions and 41 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ require (
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect github.com/quic-go/quic-go v0.60.0 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af
github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/sagernet/sing v0.8.10 // indirect github.com/sagernet/sing v0.8.10 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
+36 -40
View File
@@ -4,7 +4,6 @@ import (
"archive/zip" "archive/zip"
"bufio" "bufio"
"bytes" "bytes"
"context"
"crypto/sha256" "crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/hex" "encoding/hex"
@@ -13,6 +12,7 @@ import (
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime/multipart"
stdnet "net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -34,6 +34,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/google/uuid" "github.com/google/uuid"
utls "github.com/refraction-networking/utls"
"github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host" "github.com/shirou/gopsutil/v4/host"
@@ -1859,55 +1860,50 @@ func walkCertFiles(node any, out []string) []string {
return out return out
} }
// GetRemoteCertHash runs `xray tls ping <server>` to fetch the live certificate // GetRemoteCertHash opens a uTLS (Chrome fingerprint) handshake to a remote
// SHA-256 of a remote endpoint — the value to put in pinnedPeerCertSha256 (pcs) // endpoint and returns the hex-encoded SHA-256 of its leaf certificate — the
// when pinning a server whose certificate file you don't hold (a CDN front, a // value to put in pinnedPeerCertSha256 (pcs) when pinning a server whose
// REALITY dest, an external proxy). Returns the unique leaf-certificate hashes. // certificate file you don't hold (a CDN front, a REALITY dest, an external
// proxy). A native handshake replaces the old `xray tls ping` subprocess so the
// real dial/handshake failure (connection refused, timeout, …) surfaces
// verbatim. `server` may be host or host:port; the port defaults to 443.
func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) { func (s *ServerService) GetRemoteCertHash(server string) ([]string, error) {
server = strings.TrimSpace(server) server = strings.TrimSpace(server)
if server == "" { if server == "" {
return nil, common.NewError("no server provided") return nil, common.NewError("no server provided")
} }
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) host, port := server, "443"
defer cancel() if h, p, err := stdnet.SplitHostPort(server); err == nil {
cmd := exec.CommandContext(ctx, xray.GetBinaryPath(), "tls", "ping", server) host, port = h, p
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil && out.Len() == 0 {
return nil, err
} }
hexRe := regexp.MustCompile(`[0-9a-fA-F]{64}`) dialer := stdnet.Dialer{Timeout: 10 * time.Second}
seen := make(map[string]struct{}) tcpConn, err := dialer.Dial("tcp", stdnet.JoinHostPort(host, port))
var leaves []string if err != nil {
for _, line := range strings.Split(out.String(), "\n") { return nil, common.NewErrorf("failed to dial %s: %s", stdnet.JoinHostPort(host, port), err)
if !strings.Contains(line, "leaf SHA256") {
continue
}
hash := strings.ToLower(hexRe.FindString(line))
if hash == "" {
continue
}
if _, ok := seen[hash]; !ok {
seen[hash] = struct{}{}
leaves = append(leaves, hash)
}
} }
if len(leaves) == 0 { defer tcpConn.Close()
// Surface why the ping produced no cert (dial refused, timeout, …) _ = tcpConn.SetDeadline(time.Now().Add(15 * time.Second))
// instead of the bare "not found" — the inbound is usually just not
// listening for TLS on the pinged port. tlsConn := utls.UClient(tcpConn, &utls.Config{
for _, line := range strings.Split(out.String(), "\n") { ServerName: host,
line = strings.TrimSpace(line) InsecureSkipVerify: true,
if strings.Contains(line, "Failed") || strings.Contains(line, "error") { NextProtos: []string{"h2", "http/1.1"},
return nil, common.NewError("no certificate hash for ", server, ": ", line) }, utls.HelloChrome_Auto)
} defer tlsConn.Close()
} if err := tlsConn.Handshake(); err != nil {
return nil, common.NewError("no certificate hash found for ", server) return nil, common.NewErrorf("tls handshake with %s failed: %s", host, err)
} }
return leaves, nil
certs := tlsConn.ConnectionState().PeerCertificates
if len(certs) == 0 {
return nil, common.NewError("no certificate returned by ", host)
}
// PeerCertificates[0] is always the leaf the connection verifies against —
// robust for IP-only self-signed certs that carry no DNS SANs.
sum := sha256.Sum256(certs[0].Raw)
return []string{hex.EncodeToString(sum[:])}, nil
} }
func (s *ServerService) GetNewEchCert(sni string) (any, error) { func (s *ServerService) GetNewEchCert(sni string) (any, error) {