mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-19 12:36:39 +00:00
* fix(controller): decode old object for delete requests Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * chore: modernize golang Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * chore: modernize golang Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * chore: modernize golang Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> * fix: preserve ca-bundles injected from external providers Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * feat: add metadata enforcement Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * feat: add metadata enforcement Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: add resourcepoolclaim validation Signed-off-by: Oliver Baehler <oliver@sudo-i.net> --------- Signed-off-by: Oliver Bähler <oliverbaehler@hotmail.com> Signed-off-by: Oliver Baehler <oliver@sudo-i.net> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
573 lines
15 KiB
Go
573 lines
15 KiB
Go
// Copyright 2020-2026 Project Capsule Authors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
//nolint:nestif
|
|
package tls
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/go-logr/logr"
|
|
"golang.org/x/sync/errgroup"
|
|
admissionregistrationv1 "k8s.io/api/admissionregistration/v1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
|
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/util/retry"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/builder"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
"sigs.k8s.io/controller-runtime/pkg/handler"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
|
|
"github.com/projectcapsule/capsule/internal/controllers/utils"
|
|
"github.com/projectcapsule/capsule/pkg/runtime/cert"
|
|
"github.com/projectcapsule/capsule/pkg/runtime/configuration"
|
|
"github.com/projectcapsule/capsule/pkg/runtime/predicates"
|
|
)
|
|
|
|
const (
|
|
certificateExpirationThreshold = 3 * 24 * time.Hour
|
|
certificateValidity = 6 * 30 * 24 * time.Hour
|
|
)
|
|
|
|
type Reconciler struct {
|
|
client.Client
|
|
|
|
Log logr.Logger
|
|
Scheme *runtime.Scheme
|
|
Namespace string
|
|
Configuration configuration.Configuration
|
|
}
|
|
|
|
func (r *Reconciler) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error {
|
|
enqueueFn := handler.EnqueueRequestsFromMapFunc(func(context.Context, client.Object) []reconcile.Request {
|
|
return []reconcile.Request{
|
|
{
|
|
NamespacedName: types.NamespacedName{
|
|
Namespace: configuration.ControllerNamespace(),
|
|
Name: r.Configuration.TLSSecretName(),
|
|
},
|
|
},
|
|
}
|
|
})
|
|
|
|
return ctrl.NewControllerManagedBy(mgr).
|
|
For(
|
|
&corev1.Secret{},
|
|
builder.WithPredicates(
|
|
predicates.NamesMatchingPredicate{
|
|
Names: []string{r.Configuration.TLSSecretName()},
|
|
},
|
|
),
|
|
).
|
|
Named("capsule/tls").
|
|
WatchesMetadata(
|
|
&admissionregistrationv1.ValidatingWebhookConfiguration{},
|
|
enqueueFn,
|
|
builder.WithPredicates(
|
|
predicates.NamesMatchingPredicate{
|
|
Names: []string{string(r.Configuration.Admission().Validating.Name)},
|
|
},
|
|
),
|
|
).
|
|
WatchesMetadata(
|
|
&admissionregistrationv1.MutatingWebhookConfiguration{},
|
|
enqueueFn,
|
|
builder.WithPredicates(
|
|
predicates.NamesMatchingPredicate{
|
|
Names: []string{string(r.Configuration.Admission().Mutating.Name)},
|
|
},
|
|
),
|
|
).
|
|
WatchesMetadata(
|
|
&apiextensionsv1.CustomResourceDefinition{},
|
|
enqueueFn,
|
|
builder.WithPredicates(
|
|
predicates.NamesMatchingPredicate{
|
|
Names: r.managedCRDNames(),
|
|
},
|
|
),
|
|
).
|
|
WithOptions(ctrlConfig.Runtime.ToControllerOptions()).
|
|
Complete(r)
|
|
}
|
|
|
|
func (r *Reconciler) Reconcile(ctx context.Context, request ctrl.Request) (ctrl.Result, error) {
|
|
log := r.Log.WithValues(
|
|
"Request.Namespace", request.Namespace,
|
|
"Request.Name", request.Name,
|
|
)
|
|
|
|
if request.Namespace == "" {
|
|
request.Namespace = r.Namespace
|
|
}
|
|
|
|
if request.Name == "" {
|
|
request.Name = r.Configuration.TLSSecretName()
|
|
}
|
|
|
|
log.V(4).Info("TLS reconciliation started")
|
|
|
|
certSecret := &corev1.Secret{}
|
|
if err := r.Get(ctx, request.NamespacedName, certSecret); err != nil {
|
|
if !apierrors.IsNotFound(err) {
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
certSecret = &corev1.Secret{}
|
|
certSecret.Name = request.Name
|
|
certSecret.Namespace = request.Namespace
|
|
certSecret.Data = map[string][]byte{}
|
|
}
|
|
|
|
if err := r.ReconcileCertificates(ctx, log, certSecret); err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
servingCert, err := cert.GetCertificateFromBytes(certSecret.Data[corev1.TLSCertKey])
|
|
if err != nil {
|
|
return ctrl.Result{}, err
|
|
}
|
|
|
|
requeueTime := servingCert.NotAfter.Add(-(certificateExpirationThreshold - time.Second))
|
|
|
|
requeueAfter := max(time.Until(requeueTime), 0)
|
|
|
|
log.V(4).Info("TLS reconciliation completed", "requeueAfter", requeueAfter.String())
|
|
|
|
return ctrl.Result{
|
|
Requeue: true,
|
|
RequeueAfter: requeueAfter,
|
|
}, nil
|
|
}
|
|
|
|
func (r *Reconciler) ReconcileCertificates(
|
|
ctx context.Context,
|
|
log logr.Logger,
|
|
certSecret *corev1.Secret,
|
|
) error {
|
|
sans, err := r.desiredWebhookSANs(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.V(4).Info(
|
|
"Resolved desired webhook certificate SANs",
|
|
"dnsNames", sans.DNSNames,
|
|
"ipAddresses", cert.IPsToStrings(sans.IPAddrs),
|
|
)
|
|
|
|
ca, caBundle, rotateServingCert, err := r.ensureCertificateMaterial(log, certSecret, sans)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.V(4).Info(
|
|
"certificate requires rotation",
|
|
"rotation", rotateServingCert,
|
|
)
|
|
|
|
if rotateServingCert {
|
|
if ca == nil {
|
|
return fmt.Errorf("cannot rotate serving certificate without CA private key")
|
|
}
|
|
|
|
crt, key, err := ca.GenerateCertificate(cert.NewCertOpts(
|
|
time.Now().Add(certificateValidity),
|
|
sans,
|
|
))
|
|
if err != nil {
|
|
log.Error(err, "cannot generate serving TLS certificate")
|
|
|
|
return err
|
|
}
|
|
|
|
certSecret.Data[corev1.TLSCertKey] = crt.Bytes()
|
|
certSecret.Data[corev1.TLSPrivateKeyKey] = key.Bytes()
|
|
|
|
if err := r.validateSecretCertificate(certSecret, sans); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := r.upsertTLSSecret(ctx, certSecret); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
caBundle = certSecret.Data[corev1.ServiceAccountRootCAKey]
|
|
if len(caBundle) == 0 {
|
|
return fmt.Errorf("missing %q field in %q secret", corev1.ServiceAccountRootCAKey, r.Configuration.TLSSecretName())
|
|
}
|
|
|
|
log.V(5).Info("Patching caBundle in managed CRD conversions")
|
|
|
|
patchGroup, groupCtx := errgroup.WithContext(ctx)
|
|
|
|
for key, managed := range r.conversionManagedCRDs() {
|
|
patchGroup.Go(func() error {
|
|
if err := r.updateManagedCustomResourceDefinition(groupCtx, managed, caBundle); err != nil {
|
|
return fmt.Errorf("cannot update managed CRD %q (%s): %w", key, managed.Name, err)
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
return patchGroup.Wait()
|
|
}
|
|
|
|
// ensureCertificateMaterial ensures that the Secret contains a stable CA
|
|
// certificate/key pair and decides whether the serving certificate must be
|
|
// regenerated.
|
|
//
|
|
// Important behavior:
|
|
// - Missing Secret or missing ca.key creates a new CA.
|
|
// - Existing valid CA is reused.
|
|
// - Serving certificate renewal never rotates the CA.
|
|
// - Legacy Secrets without ca.key rotate once into the stable format.
|
|
func (r *Reconciler) ensureCertificateMaterial(
|
|
log logr.Logger,
|
|
certSecret *corev1.Secret,
|
|
sans cert.CertificateSANs,
|
|
) (*cert.CapsuleCA, []byte, bool, error) {
|
|
sans = sans.Normalize()
|
|
|
|
if sans.Empty() {
|
|
return nil, nil, false, fmt.Errorf("cannot ensure TLS material without SANs")
|
|
}
|
|
|
|
if certSecret.Data == nil {
|
|
certSecret.Data = map[string][]byte{}
|
|
}
|
|
|
|
caBundle := certSecret.Data[corev1.ServiceAccountRootCAKey]
|
|
caKey := certSecret.Data["ca.key"]
|
|
|
|
hasCABundle := len(caBundle) > 0
|
|
hasCAKey := len(caKey) > 0
|
|
|
|
var ca *cert.CapsuleCA
|
|
|
|
rotateServingCert := false
|
|
|
|
switch {
|
|
case hasCABundle && hasCAKey:
|
|
loadedCA, err := cert.NewCertificateAuthorityFromBytes(caBundle, caKey)
|
|
if err != nil {
|
|
log.V(3).Info(
|
|
"Existing CA material is invalid, generating new CA",
|
|
"error", err.Error(),
|
|
)
|
|
|
|
generatedCA, generatedCABundle, generatedCAKey, err := generateCertificateAuthorityMaterial()
|
|
if err != nil {
|
|
return nil, nil, false, err
|
|
}
|
|
|
|
ca = generatedCA
|
|
caBundle = generatedCABundle
|
|
|
|
certSecret.Data[corev1.ServiceAccountRootCAKey] = generatedCABundle
|
|
certSecret.Data["ca.key"] = generatedCAKey
|
|
|
|
rotateServingCert = true
|
|
} else {
|
|
ca = loadedCA
|
|
}
|
|
|
|
case hasCABundle && !hasCAKey:
|
|
// Legacy mode: we can validate and patch caBundle, but we cannot issue
|
|
// a new serving certificate without the CA private key.
|
|
log.V(10).Info(
|
|
"TLS Secret contains CA bundle but no CA private key; running in legacy CA mode",
|
|
"secret", client.ObjectKeyFromObject(certSecret).String(),
|
|
)
|
|
|
|
if err := r.validateSecretCertificate(certSecret, sans); err != nil {
|
|
return nil, nil, false, fmt.Errorf(
|
|
"TLS Secret %s contains legacy CA material without ca.key and the serving certificate is invalid: %w",
|
|
client.ObjectKeyFromObject(certSecret).String(),
|
|
err,
|
|
)
|
|
}
|
|
|
|
return nil, caBundle, false, nil
|
|
|
|
default:
|
|
log.V(10).Info(
|
|
"TLS Secret is missing CA material, generating new CA",
|
|
"secret", client.ObjectKeyFromObject(certSecret).String(),
|
|
)
|
|
|
|
generatedCA, generatedCABundle, generatedCAKey, err := generateCertificateAuthorityMaterial()
|
|
if err != nil {
|
|
return nil, nil, false, err
|
|
}
|
|
|
|
ca = generatedCA
|
|
caBundle = generatedCABundle
|
|
|
|
certSecret.Data[corev1.ServiceAccountRootCAKey] = generatedCABundle
|
|
certSecret.Data["ca.key"] = generatedCAKey
|
|
|
|
rotateServingCert = true
|
|
}
|
|
|
|
servingCertPEM := certSecret.Data[corev1.TLSCertKey]
|
|
servingKeyPEM := certSecret.Data[corev1.TLSPrivateKeyKey]
|
|
|
|
if len(servingCertPEM) == 0 || len(servingKeyPEM) == 0 {
|
|
log.V(10).Info(
|
|
"TLS Secret is missing serving certificate material, rotating serving certificate",
|
|
"secret", client.ObjectKeyFromObject(certSecret).String(),
|
|
)
|
|
|
|
rotateServingCert = true
|
|
} else {
|
|
servingCert, err := cert.GetCertificateFromBytes(servingCertPEM)
|
|
if err != nil {
|
|
log.V(10).Info(
|
|
"Failed to parse serving certificate, rotating serving certificate",
|
|
"secret", client.ObjectKeyFromObject(certSecret).String(),
|
|
"error", err.Error(),
|
|
)
|
|
|
|
rotateServingCert = true
|
|
} else {
|
|
if time.Until(servingCert.NotAfter) <= certificateExpirationThreshold {
|
|
log.V(10).Info(
|
|
"Serving certificate is close to expiry, rotating serving certificate",
|
|
"secret", client.ObjectKeyFromObject(certSecret).String(),
|
|
"notAfter", servingCert.NotAfter,
|
|
)
|
|
|
|
rotateServingCert = true
|
|
}
|
|
|
|
if !sans.MatchesCertificate(servingCert) {
|
|
log.V(3).Info(
|
|
"Serving certificate SANs differ from desired SANs, rotating serving certificate",
|
|
"secret", client.ObjectKeyFromObject(certSecret).String(),
|
|
"desiredDNSNames", sans.DNSNames,
|
|
"desiredIPAddresses", cert.IPsToStrings(sans.IPAddrs),
|
|
"currentDNSNames", servingCert.DNSNames,
|
|
"currentIPAddresses", cert.IPsToStrings(servingCert.IPAddresses),
|
|
)
|
|
|
|
rotateServingCert = true
|
|
}
|
|
|
|
if err := r.validateSecretCertificate(certSecret, sans); err != nil {
|
|
log.V(10).Info(
|
|
"Serving certificate failed validation, rotating serving certificate",
|
|
"secret", client.ObjectKeyFromObject(certSecret).String(),
|
|
"error", err.Error(),
|
|
)
|
|
|
|
rotateServingCert = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if rotateServingCert && ca == nil {
|
|
return nil, nil, false, fmt.Errorf(
|
|
"cannot rotate serving certificate for TLS Secret %s without CA private key",
|
|
client.ObjectKeyFromObject(certSecret).String(),
|
|
)
|
|
}
|
|
|
|
return ca, caBundle, rotateServingCert, nil
|
|
}
|
|
|
|
func generateCertificateAuthorityMaterial() (*cert.CapsuleCA, []byte, []byte, error) {
|
|
ca, err := cert.GenerateCertificateAuthority()
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
caCrt, err := ca.CACertificatePem()
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
caKey, err := ca.CAPrivateKeyPem()
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
return ca, caCrt.Bytes(), caKey.Bytes(), nil
|
|
}
|
|
|
|
func (r *Reconciler) upsertTLSSecret(ctx context.Context, certSecret *corev1.Secret) error {
|
|
desired := &corev1.Secret{
|
|
ObjectMeta: certSecret.ObjectMeta,
|
|
Type: corev1.SecretTypeTLS,
|
|
}
|
|
|
|
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, desired, func() error {
|
|
if desired.Labels == nil {
|
|
desired.Labels = map[string]string{}
|
|
}
|
|
|
|
if desired.Annotations == nil {
|
|
desired.Annotations = map[string]string{}
|
|
}
|
|
|
|
desired.Data = copySecretData(certSecret.Data)
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
r.Log.Error(err, "cannot update Capsule TLS Secret")
|
|
|
|
return err
|
|
}
|
|
|
|
certSecret.ObjectMeta = desired.ObjectMeta
|
|
certSecret.Data = copySecretData(desired.Data)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Reconciler) validateSecretCertificate(
|
|
certSecret *corev1.Secret,
|
|
sans cert.CertificateSANs,
|
|
) error {
|
|
caPEM := certSecret.Data[corev1.ServiceAccountRootCAKey]
|
|
if len(caPEM) == 0 {
|
|
return fmt.Errorf("missing %q in TLS Secret %s/%s",
|
|
corev1.ServiceAccountRootCAKey,
|
|
certSecret.Namespace,
|
|
certSecret.Name,
|
|
)
|
|
}
|
|
|
|
roots := x509.NewCertPool()
|
|
if ok := roots.AppendCertsFromPEM(caPEM); !ok {
|
|
return fmt.Errorf("failed to parse %q in TLS Secret %s/%s",
|
|
corev1.ServiceAccountRootCAKey,
|
|
certSecret.Namespace,
|
|
certSecret.Name,
|
|
)
|
|
}
|
|
|
|
leaf, err := cert.GetCertificateFromBytes(certSecret.Data[corev1.TLSCertKey])
|
|
if err != nil {
|
|
return fmt.Errorf("parse serving certificate from TLS Secret %s/%s: %w",
|
|
certSecret.Namespace,
|
|
certSecret.Name,
|
|
err,
|
|
)
|
|
}
|
|
|
|
keyPEM := certSecret.Data[corev1.TLSPrivateKeyKey]
|
|
if len(keyPEM) == 0 {
|
|
return fmt.Errorf("missing %q in TLS Secret %s/%s",
|
|
corev1.TLSPrivateKeyKey,
|
|
certSecret.Namespace,
|
|
certSecret.Name,
|
|
)
|
|
}
|
|
|
|
key, err := cert.GetPrivateKeyFromBytes(keyPEM)
|
|
if err != nil {
|
|
return fmt.Errorf("parse serving private key from TLS Secret %s/%s: %w",
|
|
certSecret.Namespace,
|
|
certSecret.Name,
|
|
err,
|
|
)
|
|
}
|
|
|
|
if err := cert.ValidateCertificate(leaf, key, certificateExpirationThreshold); err != nil {
|
|
return fmt.Errorf("serving certificate/key pair is invalid or expiring: %w", err)
|
|
}
|
|
|
|
normalized := sans.Normalize()
|
|
if normalized.Empty() {
|
|
return fmt.Errorf("cannot validate serving certificate without desired SANs")
|
|
}
|
|
|
|
for _, dnsName := range normalized.DNSNames {
|
|
if _, err := leaf.Verify(x509.VerifyOptions{
|
|
DNSName: dnsName,
|
|
Roots: roots,
|
|
KeyUsages: []x509.ExtKeyUsage{
|
|
x509.ExtKeyUsageServerAuth,
|
|
},
|
|
}); err != nil {
|
|
return fmt.Errorf("serving certificate is not valid for DNS SAN %q: %w", dnsName, err)
|
|
}
|
|
}
|
|
|
|
for _, ip := range normalized.IPAddrs {
|
|
if _, err := leaf.Verify(x509.VerifyOptions{
|
|
DNSName: ip.String(),
|
|
Roots: roots,
|
|
KeyUsages: []x509.ExtKeyUsage{
|
|
x509.ExtKeyUsageServerAuth,
|
|
},
|
|
}); err != nil {
|
|
return fmt.Errorf("serving certificate is not valid for IP SAN %q: %w", ip.String(), err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (r *Reconciler) updateManagedCustomResourceDefinition(
|
|
ctx context.Context,
|
|
managed ManagedCRD,
|
|
caBundle []byte,
|
|
) error {
|
|
if !managed.ManageConversion {
|
|
return nil
|
|
}
|
|
|
|
return retry.RetryOnConflict(retry.DefaultBackoff, func() error {
|
|
crd := &apiextensionsv1.CustomResourceDefinition{}
|
|
if err := r.Get(ctx, types.NamespacedName{Name: managed.Name}, crd); err != nil {
|
|
if apierrors.IsNotFound(err) {
|
|
return nil
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// Only patch CRDs that already use webhook conversion.
|
|
if crd.Spec.Conversion == nil ||
|
|
crd.Spec.Conversion.Webhook == nil ||
|
|
crd.Spec.Conversion.Webhook.ClientConfig == nil {
|
|
return nil
|
|
}
|
|
|
|
current := crd.Spec.Conversion.Webhook.ClientConfig.CABundle
|
|
if bytes.Equal(current, caBundle) {
|
|
return nil
|
|
}
|
|
|
|
before := crd.DeepCopy()
|
|
|
|
crd.Spec.Conversion.Webhook.ClientConfig.CABundle = append([]byte(nil), caBundle...)
|
|
|
|
return r.Patch(ctx, crd, client.MergeFrom(before))
|
|
})
|
|
}
|
|
|
|
func copySecretData(in map[string][]byte) map[string][]byte {
|
|
out := make(map[string][]byte, len(in))
|
|
|
|
for key, value := range in {
|
|
out[key] = append([]byte(nil), value...)
|
|
}
|
|
|
|
return out
|
|
}
|