Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 55 additions & 23 deletions internal/deployer/crs.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"time"

"github.com/stackrox/roxie/internal/k8s"
"github.com/stackrox/roxie/internal/logger"
)

const (
Expand Down Expand Up @@ -111,6 +112,7 @@ func (d *Deployer) centralHTTPClient() (*http.Client, error) {
}
pool.AddCert(cert)
caCertsAdded++
d.logger.Dimf("CA cert #%d: Subject.CN=%q, Issuer.CN=%q, SubjectKeyId=%x", caCertsAdded, cert.Subject.CommonName, cert.Issuer.CommonName, cert.SubjectKeyId)
}
d.logger.Infof("Loaded %d CA certificate(s) from %q", caCertsAdded, d.roxCACertFile)
tlsConfig.RootCAs = pool
Expand All @@ -119,7 +121,7 @@ func (d *Deployer) centralHTTPClient() (*http.Client, error) {
if err != nil {
return nil, fmt.Errorf("parsing central endpoint %q: %w", d.centralEndpoint, err)
}
tlsConfig.VerifyPeerCertificate = centralVerifyFunc(host, tlsConfig)
tlsConfig.VerifyPeerCertificate = centralVerifyFunc(d.logger, host, tlsConfig)
}

return &http.Client{
Expand Down Expand Up @@ -187,15 +189,22 @@ func (d *Deployer) isRetryableError(err error) bool {
}

// centralVerifyFunc returns a custom TLS peer certificate verifier for Central.
// Central's self-signed serving cert uses IP SANs and the internal DNS name
// "central.stackrox" rather than the external hostname roxie connects to (e.g.
// a port-forwarded localhost or LoadBalancer IP). Go's default TLS verification
// rejects the cert because the hostname doesn't match any SAN. We work around
// this by setting InsecureSkipVerify and performing chain verification ourselves:
// first against the actual hostname (which may work if the user added a matching
// SAN), then falling back to "central.stackrox" for certs issued by Central's
// own service CA.
func centralVerifyFunc(hostname string, conf *tls.Config) func([][]byte, [][]*x509.Certificate) error {
//
// Go's default TLS verifier is disabled (InsecureSkipVerify) because roxie
// often connects via port-forward to 127.0.0.1, which never matches any SAN.
// This function performs certificate verification in its place.
//
// Two modes based on connection type:
//
// - Port-forward (hostname is 127.0.0.1): chain verification only. Hostname
// checking is unnecessary because kubectl port-forward provides transport-
// level assurance that we're reaching the correct pod.
//
// - Direct connection (LoadBalancer, service DNS): chain verification plus
// hostname checking. If the hostname doesn't match, falls back to
// "central.stackrox" for certs issued by the StackRox internal CA, since
// those use internal DNS names rather than the external endpoint.
func centralVerifyFunc(log *logger.Logger, hostname string, conf *tls.Config) func([][]byte, [][]*x509.Certificate) error {
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
if len(rawCerts) == 0 {
return errors.New("remote peer presented no certificates")
Expand All @@ -216,28 +225,44 @@ func centralVerifyFunc(hostname string, conf *tls.Config) func([][]byte, [][]*x5
intermediates.AddCert(cert)
}

systemVerifyOpts := x509.VerifyOptions{
log.Dimf("Leaf cert: Subject.CN=%q, Issuer.CN=%q, AuthorityKeyId=%x",
leaf.Subject.CommonName, leaf.Issuer.CommonName, leaf.AuthorityKeyId)

// Port-forward: chain verification only.
if isLoopback(hostname) {
log.Dim("Port-forward connection: verifying certificate chain only")
_, err := leaf.Verify(x509.VerifyOptions{
Intermediates: intermediates,
Roots: conf.RootCAs,
})
return err
}

// Direct connection: chain + hostname verification.
log.Dimf("Direct connection: verifying against hostname %s", hostname)
verifyOpts := x509.VerifyOptions{
DNSName: hostname,
Intermediates: intermediates,
Roots: conf.RootCAs,
}

_, systemVerifyErr := leaf.Verify(systemVerifyOpts)
if systemVerifyErr == nil || !isACentralCert(leaf) {
return systemVerifyErr
_, err := leaf.Verify(verifyOpts)
if err == nil {
return nil
}
var hostErr x509.HostnameError
if !errors.As(err, &hostErr) || !isACentralCert(leaf) {
return err
}

serviceVerifyOpts := x509.VerifyOptions{
// Fallback for StackRox service certs: the internal cert uses
// "central.stackrox" as its SAN, not the external endpoint hostname.
log.Dim("Falling back to central.stackrox for StackRox service cert")
_, err = leaf.Verify(x509.VerifyOptions{
DNSName: "central.stackrox",
Intermediates: intermediates,
Roots: conf.RootCAs,
}

_, serviceVerifyErr := leaf.Verify(serviceVerifyOpts)
if serviceVerifyErr == nil {
return nil
}
return errors.Join(systemVerifyErr, serviceVerifyErr)
})
return err
}
}

Expand All @@ -252,6 +277,13 @@ func isACentralCert(cert *x509.Certificate) bool {
return false
}

func isLoopback(hostname string) bool {
if ip := net.ParseIP(hostname); ip != nil {
return ip.IsLoopback()
}
return hostname == "localhost"
}

// applyCRS applies the CRS content to the sensor namespace
func (d *Deployer) applyCRS(ctx context.Context, crsContent string) error {
d.logger.Info("Applying CRS to sensor namespace")
Expand Down
117 changes: 91 additions & 26 deletions internal/deployer/deploy_via_operator.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package deployer
import (
"bytes"
"context"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"

Expand Down Expand Up @@ -558,48 +560,111 @@ func (d *Deployer) waitForLoadBalancer(ctx context.Context, namespace, serviceNa
return "", errors.New("timeout waiting for LoadBalancer to get external address")
}

// fetchCentralCACert fetches the Central CA certificate
func (d *Deployer) fetchCentralCACert(ctx context.Context) error {
d.logger.Info("Fetching Central CA certificate...")
// fetchCentralCACerts fetches CA certificates needed to verify Central's TLS cert.
// It always fetches the internal StackRox CA from the central-tls secret, and
// additionally fetches CA certs from the defaultTLSSecret if one is configured
// in the Central CR spec.
func (d *Deployer) fetchCentralCACerts(ctx context.Context) error {
var caPEMs [][]byte

result, err := d.runKubectl(ctx, k8s.KubectlOptions{
Args: []string{"get", "secret", "central-tls", "-n", d.config.Central.Namespace, "-o", "jsonpath={.data.ca\\.pem}"},
})
// Fetch the internal StackRox CA from the central-tls secret.
d.logger.Info("Fetching internal CA certificate from central-tls secret...")
internalCA, err := d.fetchSecretField(ctx, "central-tls", "ca\\.pem")
if err != nil {
return fmt.Errorf("failed to get CA cert from secret: %w", err)
}

caCertBase64 := strings.TrimSpace(result.Stdout)
if caCertBase64 == "" {
return errors.New("CA certificate is empty")
return fmt.Errorf("failed to get CA cert from central-tls secret: %w", err)
}
caPEMs = append(caPEMs, internalCA)

decodeCmd := exec.CommandContext(ctx, "base64", "-d")
decodeCmd.Stdin = strings.NewReader(caCertBase64)
caCert, err := decodeCmd.Output()
if err != nil {
return fmt.Errorf("failed to decode CA cert: %w", err)
// If a custom defaultTLSSecret is configured, add its certs to the
// trust pool. This includes the leaf — Central itself does the same
// when building the trust bundle for Sensor.
if secretName := d.defaultTLSSecretName(); secretName != "" {
d.logger.Infof("Fetching custom TLS certificates from secret %s...", secretName)
customCerts, err := d.fetchCustomTLSCerts(ctx, secretName)
if err != nil {
d.logger.Warningf("Could not fetch custom TLS certs from secret %s: %v", secretName, err)
// Try to continue.
} else {
caPEMs = append(caPEMs, customCerts...)
}
}

caCertFile, err := os.CreateTemp(d.tempDir, "roxie-central-ca-*.pem")
if err != nil {
return fmt.Errorf("failed to create temp file for CA cert: %w", err)
}

d.roxCACertFile = caCertFile.Name()
if _, err := caCertFile.Write(caCert); err != nil {
_ = caCertFile.Close()
_ = os.Remove(d.roxCACertFile)
return fmt.Errorf("failed to write CA cert: %w", err)
fileName := caCertFile.Name()
for _, pemData := range caPEMs {
pemData = append(bytes.TrimRight(pemData, "\n"), '\n')
if _, err := caCertFile.Write(pemData); err != nil {
_ = caCertFile.Close()
_ = os.Remove(fileName)
return fmt.Errorf("failed to write CA cert: %w", err)
}
}
Comment thread
mclasmeier marked this conversation as resolved.
if err := caCertFile.Close(); err != nil {
_ = os.Remove(fileName)
return fmt.Errorf("failed to close CA cert file: %w", err)
}

d.logger.Successf("✓ CA certificate saved to: %s", d.roxCACertFile)
d.roxCACertFile = fileName
d.logger.Successf("✓ CA certificates saved to: %s", d.roxCACertFile)
return nil
}

// defaultTLSSecretName returns the name of the defaultTLSSecret from the Central
// CR spec, or "" if none is configured.
func (d *Deployer) defaultTLSSecretName() string {
name, _, _ := unstructured.NestedString(d.config.Central.Spec, "central", "defaultTLSSecret", "name")
return name
}

// fetchSecretField fetches a single base64-encoded field from a Kubernetes secret
// in the Central namespace and returns the decoded PEM bytes.
func (d *Deployer) fetchSecretField(ctx context.Context, secretName, jsonpathField string) ([]byte, error) {
result, err := d.runKubectl(ctx, k8s.KubectlOptions{
Args: []string{"get", "secret", secretName, "-n", d.config.Central.Namespace, "-o", fmt.Sprintf("jsonpath={.data.%s}", jsonpathField)},
})
if err != nil {
return nil, fmt.Errorf("kubectl get secret %s: %w", secretName, err)
}

b64 := strings.TrimSpace(result.Stdout)
if b64 == "" {
return nil, fmt.Errorf("field %s in secret %s is empty", jsonpathField, secretName)
}

decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return nil, fmt.Errorf("base64 decode of %s from %s: %w", jsonpathField, secretName, err)
}
return decoded, nil
}

// fetchCustomTLSCerts fetches the tls.crt field from a Kubernetes TLS secret
// and returns all certificates (leaf, intermediates, root) as PEM blocks.
// All certs are added to the trust pool, matching how Central itself builds
// the trust bundle for Sensor.
func (d *Deployer) fetchCustomTLSCerts(ctx context.Context, secretName string) ([][]byte, error) {
certBundle, err := d.fetchSecretField(ctx, secretName, "tls\\.crt")
if err != nil {
return nil, fmt.Errorf("fetching certificates from secret %s: %w", secretName, err)
}

var pems [][]byte
for block, rest := pem.Decode(certBundle); block != nil; block, rest = pem.Decode(rest) {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing certificate from secret %s: %w", secretName, err)
}
d.logger.Infof("Found certificate in %s: Subject.CN=%q, IsCA=%v",
secretName, cert.Subject.CommonName, cert.IsCA)
pems = append(pems, pem.EncodeToMemory(block))
}
return pems, nil
}

// configureCentralEndpoint configures the central endpoint in the Deployer based on exposure settings.
func (d *Deployer) configureCentralEndpoint(ctx context.Context) error {
exposure := d.config.Central.GetExposure()
Expand Down Expand Up @@ -639,8 +704,8 @@ func (d *Deployer) configureCentralEndpoint(ctx context.Context) error {
d.centralEndpoint = "central." + d.config.Central.Namespace + ".svc:443"
}

if err := d.fetchCentralCACert(ctx); err != nil {
d.logger.Warningf("Could not fetch CA cert: %v", err)
if err := d.fetchCentralCACerts(ctx); err != nil {
d.logger.Warningf("Could not fetch Central CA certs: %v", err)
}

if env.RunningInteractively {
Expand Down
Loading
Loading