Procházet zdrojové kódy

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.
MHSanaei před 15 hodinami
rodič
revize
0483273839
2 změnil soubory, kde provedl 37 přidání a 41 odebrání
  1. 1 1
      go.mod
  2. 36 40
      internal/web/service/server.go

+ 1 - 1
go.mod

@@ -86,7 +86,7 @@ require (
 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
 	github.com/quic-go/qpack v0.6.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/sagernet/sing v0.8.10 // indirect
 	github.com/sagernet/sing-shadowsocks v0.2.9 // indirect

+ 36 - 40
internal/web/service/server.go

@@ -4,7 +4,6 @@ import (
 	"archive/zip"
 	"bufio"
 	"bytes"
-	"context"
 	"crypto/sha256"
 	"crypto/x509"
 	"encoding/hex"
@@ -13,6 +12,7 @@ import (
 	"fmt"
 	"io"
 	"mime/multipart"
+	stdnet "net"
 	"net/http"
 	"net/url"
 	"os"
@@ -34,6 +34,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"github.com/google/uuid"
+	utls "github.com/refraction-networking/utls"
 	"github.com/shirou/gopsutil/v4/cpu"
 	"github.com/shirou/gopsutil/v4/disk"
 	"github.com/shirou/gopsutil/v4/host"
@@ -1859,55 +1860,50 @@ func walkCertFiles(node any, out []string) []string {
 	return out
 }
 
-// GetRemoteCertHash runs `xray tls ping <server>` to fetch the live certificate
-// SHA-256 of a remote endpoint — the value to put in pinnedPeerCertSha256 (pcs)
-// when pinning a server whose certificate file you don't hold (a CDN front, a
-// REALITY dest, an external proxy). Returns the unique leaf-certificate hashes.
+// GetRemoteCertHash opens a uTLS (Chrome fingerprint) handshake to a remote
+// endpoint and returns the hex-encoded SHA-256 of its leaf certificate — the
+// value to put in pinnedPeerCertSha256 (pcs) when pinning a server whose
+// 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) {
 	server = strings.TrimSpace(server)
 	if server == "" {
 		return nil, common.NewError("no server provided")
 	}
 
-	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
-	defer cancel()
-	cmd := exec.CommandContext(ctx, xray.GetBinaryPath(), "tls", "ping", server)
-	var out bytes.Buffer
-	cmd.Stdout = &out
-	cmd.Stderr = &out
-	if err := cmd.Run(); err != nil && out.Len() == 0 {
-		return nil, err
+	host, port := server, "443"
+	if h, p, err := stdnet.SplitHostPort(server); err == nil {
+		host, port = h, p
 	}
 
-	hexRe := regexp.MustCompile(`[0-9a-fA-F]{64}`)
-	seen := make(map[string]struct{})
-	var leaves []string
-	for _, line := range strings.Split(out.String(), "\n") {
-		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)
-		}
+	dialer := stdnet.Dialer{Timeout: 10 * time.Second}
+	tcpConn, err := dialer.Dial("tcp", stdnet.JoinHostPort(host, port))
+	if err != nil {
+		return nil, common.NewErrorf("failed to dial %s: %s", stdnet.JoinHostPort(host, port), err)
 	}
-	if len(leaves) == 0 {
-		// Surface why the ping produced no cert (dial refused, timeout, …)
-		// instead of the bare "not found" — the inbound is usually just not
-		// listening for TLS on the pinged port.
-		for _, line := range strings.Split(out.String(), "\n") {
-			line = strings.TrimSpace(line)
-			if strings.Contains(line, "Failed") || strings.Contains(line, "error") {
-				return nil, common.NewError("no certificate hash for ", server, ": ", line)
-			}
-		}
-		return nil, common.NewError("no certificate hash found for ", server)
+	defer tcpConn.Close()
+	_ = tcpConn.SetDeadline(time.Now().Add(15 * time.Second))
+
+	tlsConn := utls.UClient(tcpConn, &utls.Config{
+		ServerName:         host,
+		InsecureSkipVerify: true,
+		NextProtos:         []string{"h2", "http/1.1"},
+	}, utls.HelloChrome_Auto)
+	defer tlsConn.Close()
+	if err := tlsConn.Handshake(); err != nil {
+		return nil, common.NewErrorf("tls handshake with %s failed: %s", host, err)
+	}
+
+	certs := tlsConn.ConnectionState().PeerCertificates
+	if len(certs) == 0 {
+		return nil, common.NewError("no certificate returned by ", host)
 	}
-	return leaves, nil
+	// 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) {