Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -2152,9 +2152,14 @@ func (r *HostedControlPlaneReconciler) reconcilePKI(ctx context.Context, hcp *hy
observedDefaultIngressCert = nil
}
}
namedCertCAs, err := r.getNamedCertificateCAs(ctx, hcp)
if err != nil {
return fmt.Errorf("failed to get named certificate CAs: %w", err)
}

rootCAConfigMap := manifests.RootCAConfigMap(hcp.Namespace)
if _, err := createOrUpdate(ctx, r, rootCAConfigMap, func() error {
return pki.ReconcileRootCAConfigMap(rootCAConfigMap, p.OwnerRef, rootCASecret, observedDefaultIngressCert)
return pki.ReconcileRootCAConfigMap(rootCAConfigMap, p.OwnerRef, rootCASecret, observedDefaultIngressCert, namedCertCAs...)
}); err != nil {
return fmt.Errorf("failed to reconcile root CA configmap: %w", err)
}
Expand Down Expand Up @@ -2250,6 +2255,57 @@ func (r *HostedControlPlaneReconciler) reconcilePlatformSpecificCerts(ctx contex
return nil
}

// getNamedCertificateCAs extracts CA certificates from named certificate Secrets
// so they can be included in the root-ca ConfigMap and ultimately propagated to
// the guest cluster's kube-root-ca.crt trust bundle.
//
// CA certificates are collected from both the tls.crt chain (non-leaf CA
// certs) and the ca.crt key (if present), merging both sources. This covers
// Secrets where the chain and the root CA are split across the two keys.
// If neither source provides a valid CA, a warning is logged.
func (r *HostedControlPlaneReconciler) getNamedCertificateCAs(ctx context.Context, hcp *hyperv1.HostedControlPlane) ([]*corev1.Secret, error) {
if hcp.Spec.Configuration == nil {
return nil, nil
}
var namedCertCAs []*corev1.Secret
for _, namedCert := range hcp.Spec.Configuration.GetNamedCertificates() {
if namedCert.ServingCertificate.Name == "" {
continue
}
certSecret := &corev1.Secret{}
if err := r.Get(ctx, client.ObjectKey{
Namespace: hcp.Namespace,
Name: namedCert.ServingCertificate.Name,
}, certSecret); err != nil {
// Return an error for all failures including NotFound so we
// never publish a root-ca ConfigMap with an incomplete CA
// bundle. The controller will requeue and the existing
// ConfigMap stays intact until all configured Secrets are
// available.
return nil, fmt.Errorf("failed to get named certificate secret %s: %w", namedCert.ServingCertificate.Name, err)
}
// Extract CAs from the tls.crt chain (non-leaf certificates)
// and also from ca.crt if present, merging both sources so that
// a Secret with leaf+intermediate in tls.crt and the root in
// ca.crt contributes the full trust chain.
caCerts := pki.ExtractCACertificatesFromChain(certSecret.Data[corev1.TLSCertKey])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if caData, ok := certSecret.Data[certs.CASignerCertMapKey]; ok && len(caData) > 0 {
caCerts = append(caCerts, pki.FilterCACertificates(caData)...)
}
if len(caCerts) > 0 {
namedCertCAs = append(namedCertCAs, &corev1.Secret{
Data: map[string][]byte{
certs.CASignerCertMapKey: caCerts,
},
})
} else {
r.Log.Info("Named certificate secret has no valid CA certificate in tls.crt chain or ca.crt; its signer will not be included in the root-ca trust bundle",
"secret", namedCert.ServingCertificate.Name)
}
}
return namedCertCAs, nil
}

func (r *HostedControlPlaneReconciler) reconcileUnmanagedEtcd(ctx context.Context, hcp *hyperv1.HostedControlPlane, createOrUpdate upsert.CreateOrUpdateFN) error {
// reconcile client secret over
if hcp.Spec.Etcd.Unmanaged == nil || len(hcp.Spec.Etcd.Unmanaged.TLS.ClientSecret.Name) == 0 || len(hcp.Spec.Etcd.Unmanaged.Endpoint) == 0 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6934,3 +6934,237 @@ func TestValidateAWSKMSConfig(t *testing.T) {
})
}
}

//go:embed testdata/named-cert-cas/root-ca.pem
var testNamedCertRootCAPEM []byte

//go:embed testdata/named-cert-cas/leaf.pem
var testNamedCertLeafPEM []byte

//go:embed testdata/named-cert-cas/chain.pem
var testNamedCertChainPEM []byte

//go:embed testdata/named-cert-cas/leaf-intermediate-chain.pem
var testNamedCertLeafIntermediateChainPEM []byte

func TestGetNamedCertificateCAs(t *testing.T) {
rootCAPEM := testNamedCertRootCAPEM
leafPEM := testNamedCertLeafPEM
chainPEM := testNamedCertChainPEM

const namespace = "test-ns"

tests := []struct {
name string
hcp *hyperv1.HostedControlPlane
objects []client.Object
expectErr bool
expectCount int
}{
{
name: "When configuration is nil, it should return nil",
hcp: &hyperv1.HostedControlPlane{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"},
},
expectCount: 0,
},
{
name: "When named cert has empty ServingCertificate name, it should skip it",
hcp: &hyperv1.HostedControlPlane{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"},
Spec: hyperv1.HostedControlPlaneSpec{
Configuration: &hyperv1.ClusterConfiguration{
APIServer: &configv1.APIServerSpec{
ServingCerts: configv1.APIServerServingCerts{
NamedCertificates: []configv1.APIServerNamedServingCert{
{ServingCertificate: configv1.SecretNameReference{Name: ""}},
},
},
},
},
},
},
expectCount: 0,
},
{
name: "When named cert Secret does not exist, it should return an error",
hcp: &hyperv1.HostedControlPlane{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"},
Spec: hyperv1.HostedControlPlaneSpec{
Configuration: &hyperv1.ClusterConfiguration{
APIServer: &configv1.APIServerSpec{
ServingCerts: configv1.APIServerServingCerts{
NamedCertificates: []configv1.APIServerNamedServingCert{
{ServingCertificate: configv1.SecretNameReference{Name: "missing-secret"}},
},
},
},
},
},
},
expectErr: true,
},
{
name: "When tls.crt contains a CA chain, it should extract the CA certificates",
hcp: &hyperv1.HostedControlPlane{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"},
Spec: hyperv1.HostedControlPlaneSpec{
Configuration: &hyperv1.ClusterConfiguration{
APIServer: &configv1.APIServerSpec{
ServingCerts: configv1.APIServerServingCerts{
NamedCertificates: []configv1.APIServerNamedServingCert{
{ServingCertificate: configv1.SecretNameReference{Name: "chain-cert"}},
},
},
},
},
},
},
objects: []client.Object{
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "chain-cert"},
Data: map[string][]byte{
corev1.TLSCertKey: chainPEM,
},
},
},
expectCount: 1,
},
{
name: "When tls.crt has only a leaf and ca.crt is present, it should fall back to ca.crt",
hcp: &hyperv1.HostedControlPlane{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"},
Spec: hyperv1.HostedControlPlaneSpec{
Configuration: &hyperv1.ClusterConfiguration{
APIServer: &configv1.APIServerSpec{
ServingCerts: configv1.APIServerServingCerts{
NamedCertificates: []configv1.APIServerNamedServingCert{
{ServingCertificate: configv1.SecretNameReference{Name: "leaf-only-cert"}},
},
},
},
},
},
},
objects: []client.Object{
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "leaf-only-cert"},
Data: map[string][]byte{
corev1.TLSCertKey: leafPEM,
certs.CASignerCertMapKey: rootCAPEM,
},
},
},
expectCount: 1,
},
{
name: "When tls.crt has only a leaf and no ca.crt, it should return empty and log a warning",
hcp: &hyperv1.HostedControlPlane{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"},
Spec: hyperv1.HostedControlPlaneSpec{
Configuration: &hyperv1.ClusterConfiguration{
APIServer: &configv1.APIServerSpec{
ServingCerts: configv1.APIServerServingCerts{
NamedCertificates: []configv1.APIServerNamedServingCert{
{ServingCertificate: configv1.SecretNameReference{Name: "no-ca-cert"}},
},
},
},
},
},
},
objects: []client.Object{
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "no-ca-cert"},
Data: map[string][]byte{
corev1.TLSCertKey: leafPEM,
},
},
},
expectCount: 0,
},
{
name: "When tls.crt has leaf and intermediate and ca.crt has the root, it should include both CAs",
hcp: &hyperv1.HostedControlPlane{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"},
Spec: hyperv1.HostedControlPlaneSpec{
Configuration: &hyperv1.ClusterConfiguration{
APIServer: &configv1.APIServerSpec{
ServingCerts: configv1.APIServerServingCerts{
NamedCertificates: []configv1.APIServerNamedServingCert{
{ServingCertificate: configv1.SecretNameReference{Name: "split-chain-cert"}},
},
},
},
},
},
},
objects: []client.Object{
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "split-chain-cert"},
Data: map[string][]byte{
corev1.TLSCertKey: testNamedCertLeafIntermediateChainPEM,
certs.CASignerCertMapKey: rootCAPEM,
},
},
},
expectCount: 1,
},
{
name: "When multiple named certs are configured, it should extract CAs from all of them",
hcp: &hyperv1.HostedControlPlane{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "hcp"},
Spec: hyperv1.HostedControlPlaneSpec{
Configuration: &hyperv1.ClusterConfiguration{
APIServer: &configv1.APIServerSpec{
ServingCerts: configv1.APIServerServingCerts{
NamedCertificates: []configv1.APIServerNamedServingCert{
{ServingCertificate: configv1.SecretNameReference{Name: "cert-a"}},
{ServingCertificate: configv1.SecretNameReference{Name: "cert-b"}},
},
},
},
},
},
},
objects: []client.Object{
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "cert-a"},
Data: map[string][]byte{
corev1.TLSCertKey: chainPEM,
},
},
&corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "cert-b"},
Data: map[string][]byte{
corev1.TLSCertKey: leafPEM,
certs.CASignerCertMapKey: rootCAPEM,
},
},
},
expectCount: 2,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
g := NewGomegaWithT(t)
fakeClient := fake.NewClientBuilder().WithObjects(tc.objects...).Build()
r := &HostedControlPlaneReconciler{
Client: fakeClient,
Log: ctrl.LoggerFrom(t.Context()),
}

result, err := r.getNamedCertificateCAs(t.Context(), tc.hcp)
if tc.expectErr {
g.Expect(err).To(HaveOccurred())
return
}
g.Expect(err).NotTo(HaveOccurred())
g.Expect(result).To(HaveLen(tc.expectCount))
for _, s := range result {
g.Expect(s.Data).To(HaveKey(certs.CASignerCertMapKey))
g.Expect(s.Data[certs.CASignerCertMapKey]).NotTo(BeEmpty())
}
})
}
}
51 changes: 50 additions & 1 deletion control-plane-operator/controllers/hostedcontrolplane/pki/ca.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ func ReconcileEtcdMetricsSignerConfigMap(cm *corev1.ConfigMap, ownerRef config.O
return reconcileAggregateCA(cm, ownerRef, etcdMetricsSigner)
}

func ReconcileRootCAConfigMap(cm *corev1.ConfigMap, ownerRef config.OwnerRef, rootCA *corev1.Secret, observedDefaultIngressCert *corev1.ConfigMap) error {
func ReconcileRootCAConfigMap(cm *corev1.ConfigMap, ownerRef config.OwnerRef, rootCA *corev1.Secret, observedDefaultIngressCert *corev1.ConfigMap, namedCertCAs ...*corev1.Secret) error {
sources := []*corev1.Secret{rootCA}
if observedDefaultIngressCert != nil {
sources = append(sources, &corev1.Secret{
Expand All @@ -185,9 +185,58 @@ func ReconcileRootCAConfigMap(cm *corev1.ConfigMap, ownerRef config.OwnerRef, ro
},
})
}
sources = append(sources, namedCertCAs...)
return reconcileAggregateCA(cm, ownerRef, sources...)
}

// FilterCACertificates parses a PEM bundle and returns only the certificates
// that are valid CAs (IsCA=true). Malformed or non-CA certificates are
// skipped.
func FilterCACertificates(certPEM []byte) []byte {
var caCerts []byte
rest := certPEM
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
continue
}
if !cert.IsCA {
continue
}
caCerts = append(caCerts, pem.EncodeToMemory(block)...)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return caCerts
}

// ExtractCACertificatesFromChain parses a PEM-encoded certificate chain and
// returns a PEM bundle containing only the non-leaf CA certificates.
// The first CERTIFICATE block (the leaf/server cert) is always skipped.
// Subsequent certificates that fail to parse or are not CAs are also skipped.
// If the chain contains only a single certificate, nil is returned.
func ExtractCACertificatesFromChain(certPEM []byte) []byte {
// Skip the first CERTIFICATE PEM block (leaf), then filter the rest.
rest := certPEM
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
return nil
}
if block.Type == "CERTIFICATE" {
break
}
}
return FilterCACertificates(rest)
}

func ReconcileKonnectivityConfigMap(cm *corev1.ConfigMap, ownerRef config.OwnerRef, signers ...*corev1.Secret) error {
return reconcileAggregateCA(cm, ownerRef, signers...)
}
Expand Down
Loading