fix(nodes): honor TLS verify mode skip/pin for remote node operations (#5264)

The node probe honored the per-node TlsVerifyMode (skip/pin) but
runtime.Remote used a shared client with no TLSClientConfig, so traffic
sync and every other remote op fell back to system-CA verification and
failed against self-signed nodes even after the operator set skip/pin.

Move the TLS client builder into the runtime layer (HTTPClientForNode /
DecodeCertPin) as the single source of truth, have Remote build and cache
its per-node client through it, and delegate the service probe to the same
builder so the two paths can no longer diverge.
This commit is contained in:
MHSanaei
2026-06-13 11:11:02 +02:00
parent 9a8247fa78
commit 4c8d3cb625
4 changed files with 271 additions and 83 deletions
+2 -73
View File
@@ -3,10 +3,8 @@ package service
import (
"context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -44,75 +42,6 @@ type HeartbeatPatch struct {
type NodeService struct{}
var nodeHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
},
}
// nodeHTTPClientFor returns the HTTP client used to reach a node, honoring its
// per-node TLS verification mode. "verify" (or any http node) uses the shared
// client with default certificate validation. "skip" disables validation.
// "pin" disables the default chain check but verifies the leaf certificate's
// SHA-256 against the stored pin, keeping MITM protection for self-signed certs.
func nodeHTTPClientFor(n *model.Node) (*http.Client, error) {
mode := n.TlsVerifyMode
if mode == "" {
mode = "verify"
}
if mode == "verify" || n.Scheme == "http" {
return nodeHTTPClient, nil
}
tlsCfg := &tls.Config{InsecureSkipVerify: true}
if mode == "pin" {
want, err := decodeCertPin(n.PinnedCertSha256)
if err != nil {
return nil, err
}
tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
if len(cs.PeerCertificates) == 0 {
return common.NewError("node presented no certificate")
}
sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
if subtle.ConstantTimeCompare(sum[:], want) != 1 {
return common.NewError("node certificate does not match pinned SHA-256")
}
return nil
}
}
return &http.Client{
Transport: &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
TLSClientConfig: tlsCfg,
},
}, nil
}
// decodeCertPin accepts a SHA-256 certificate hash as base64 (the format used
// by Xray's pinnedPeerCertSha256) or hex with optional colons (the openssl
// -fingerprint style) and returns the 32 raw bytes.
func decodeCertPin(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, common.NewError("certificate pin is empty")
}
if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
return b, nil
}
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
return b, nil
}
}
return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
}
// FetchCertFingerprint connects to the node over HTTPS without verifying the
// certificate and returns the leaf certificate's SHA-256 as base64, so the UI
// can offer a "fetch and pin current certificate" action.
@@ -367,7 +296,7 @@ func (s *NodeService) normalize(n *model.Node) error {
n.InboundTags = tags
}
if n.TlsVerifyMode == "pin" {
if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
if _, err := runtime.DecodeCertPin(n.PinnedCertSha256); err != nil {
return common.NewError(err.Error())
}
}
@@ -692,7 +621,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
}
req.Header.Set("Accept", "application/json")
client, err := nodeHTTPClientFor(n)
client, err := runtime.HTTPClientForNode(n)
if err != nil {
patch.LastError = err.Error()
return patch, err