Parcourir la source

feat: add provider certificate automation

Moritz Johner il y a 1 mois
Parent
commit
47df22dfbb

+ 37 - 0
cmd/controller/certcontroller.go

@@ -38,6 +38,7 @@ import (
 
 	ctrlcommon "github.com/external-secrets/external-secrets/pkg/controllers/common"
 	"github.com/external-secrets/external-secrets/pkg/controllers/crds"
+	"github.com/external-secrets/external-secrets/pkg/controllers/providercerts"
 	"github.com/external-secrets/external-secrets/pkg/controllers/webhookconfig"
 	"github.com/external-secrets/external-secrets/runtime/constants"
 )
@@ -122,6 +123,21 @@ var certcontrollerCmd = &cobra.Command{
 			os.Exit(1)
 		}
 
+		// Setup provider certificate reconciler if configured
+		if providerConfig := buildProviderConfig(); providerConfig != nil {
+			providerCertReconciler := providercerts.New(
+				mgr.GetClient(),
+				ctrl.Log.WithName("provider-certs"),
+				providerConfig,
+				crdRequeueInterval,
+				mgr.Elected(),
+			)
+			if err := mgr.Add(providerCertReconciler); err != nil {
+				setupLog.Error(err, "unable to add provider cert reconciler")
+				os.Exit(1)
+			}
+		}
+
 		whc := webhookconfig.New(mgr.GetClient(), mgr.GetScheme(), mgr.Elected(),
 			ctrl.Log.WithName("controllers").WithName("webhook-certs-updater"),
 			webhookconfig.Opts{
@@ -155,6 +171,25 @@ var certcontrollerCmd = &cobra.Command{
 	},
 }
 
+func buildProviderConfig() *providercerts.ProviderCertConfig {
+	if providerNamespace == "" {
+		return nil
+	}
+	if len(providerServiceNames) == 0 {
+		return nil
+	}
+	serviceNames := make([]string, 0, len(providerServiceNames))
+	for _, serviceName := range providerServiceNames {
+		if serviceName == "" {
+			continue
+		}
+		serviceNames = append(serviceNames, serviceName)
+	}
+	return &providercerts.ProviderCertConfig{
+		Namespace:    providerNamespace,
+		ServiceNames: serviceNames,
+	}
+}
 func setupLogger() {
 	var lvl zapcore.Level
 	var enc zapcore.TimeEncoder
@@ -198,4 +233,6 @@ func init() {
 	certcontrollerCmd.Flags().DurationVar(&crdRequeueInterval, "crd-requeue-interval", time.Minute*5, "Time duration between reconciling CRDs for new certs")
 	certcontrollerCmd.Flags().BoolVar(&enableHTTP2, "enable-http2", false,
 		"If set, HTTP/2 will be enabled for the metrics server")
+	certcontrollerCmd.Flags().StringVar(&providerNamespace, "provider-namespace", "", "Provider namespace")
+	certcontrollerCmd.Flags().StringSliceVar(&providerServiceNames, "provider-service-names", []string{}, "Provider service names for DNS SANs")
 }

+ 2 - 0
cmd/controller/root.go

@@ -111,6 +111,8 @@ var (
 	tlsMinVersion                         string
 	enableHTTP2                           bool
 	allowGenericTargets                   bool
+	providerNamespace                     string
+	providerServiceNames                  []string
 )
 
 const (

+ 531 - 0
pkg/controllers/providercerts/certs.go

@@ -0,0 +1,531 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Package providercerts manages TLS certificates for external secrets providers.
+package providercerts
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"fmt"
+	"math/big"
+	"slices"
+	"time"
+
+	corev1 "k8s.io/api/core/v1"
+	apierrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+)
+
+const (
+	caCertName     = "ca.crt"
+	caKeyName      = "ca.key"
+	certName       = "tls.crt"
+	keyName        = "tls.key"
+	clientCertName = "client.crt"
+	clientKeyName  = "client.key"
+
+	// we're using a fixed secret name for the provider certificates
+	// otherwise we would need to introduce a lot of complexity to the core controller
+	// because we would have to maintain a mapping of provider names (aws) to provider kinds (parameterstore, generators etc.)
+	providerSecretName = "external-secrets-provider-tls"
+
+	// certValidityDuration is the validity period for generated certificates (10 years).
+	certValidityDuration = 87600 * time.Hour
+
+	errGeneratePrivateKey = "failed to generate private key: %w"
+)
+
+// ProviderCertConfig defines configuration for a single provider's certificates.
+type ProviderCertConfig struct {
+	Namespace    string   // Namespace where provider is deployed
+	ServiceNames []string // Service names for DNS SANs
+}
+
+// ProviderCertificates holds the generated certificates for a provider.
+type ProviderCertificates struct {
+	ServerCert []byte
+	ServerKey  []byte
+	ClientCert []byte
+	ClientKey  []byte
+}
+
+// KeyPairArtifacts holds a certificate and private key.
+type KeyPairArtifacts struct {
+	Cert    *x509.Certificate
+	Key     *rsa.PrivateKey
+	CertPEM []byte
+	KeyPEM  []byte
+}
+
+// ReconcileProviderCert ensures provider certificates exist and are valid.
+func (r *ProviderCertReconciler) ReconcileProviderCert(ctx context.Context, config *ProviderCertConfig) error {
+	if config == nil {
+		return nil
+	}
+	log := r.Log.WithValues("secret", providerSecretName, "namespace", config.Namespace)
+
+	// Get or create the secret
+	secretName := types.NamespacedName{
+		Name:      providerSecretName,
+		Namespace: config.Namespace,
+	}
+
+	var secret corev1.Secret
+	err := r.Get(ctx, secretName, &secret)
+	if err != nil {
+		if !apierrors.IsNotFound(err) {
+			return fmt.Errorf("failed to get provider secret: %w", err)
+		}
+
+		// Secret doesn't exist, create it
+		log.Info("creating provider certificate secret")
+		secret = corev1.Secret{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      providerSecretName,
+				Namespace: config.Namespace,
+				Labels: map[string]string{
+					"app.kubernetes.io/managed-by": "external-secrets",
+					"app.kubernetes.io/component":  "provider-certificates",
+				},
+			},
+			Type: corev1.SecretTypeTLS,
+		}
+	}
+
+	// Check if certificates need refresh
+	needRefresh := r.needProviderCertRefresh(&secret, config)
+
+	if needRefresh {
+		log.Info("refreshing provider certificates")
+		if err := r.refreshProviderCerts(&secret, config); err != nil {
+			return fmt.Errorf("failed to refresh provider certificates: %w", err)
+		}
+
+		// Create or update the secret
+		if secret.UID == "" {
+			if err := r.Create(ctx, &secret); err != nil {
+				return fmt.Errorf("failed to create provider secret: %w", err)
+			}
+			log.Info("created provider certificate secret")
+		} else {
+			if err := r.Update(ctx, &secret); err != nil {
+				return fmt.Errorf("failed to update provider secret: %w", err)
+			}
+			log.Info("updated provider certificate secret")
+		}
+	}
+
+	return nil
+}
+
+// needProviderCertRefresh checks if provider certificates need to be refreshed.
+func (r *ProviderCertReconciler) needProviderCertRefresh(secret *corev1.Secret, config *ProviderCertConfig) bool {
+	// If secret has no data, we need to generate certificates
+	if secret.Data == nil {
+		return true
+	}
+
+	// Check if all required keys exist
+	requiredKeys := []string{caCertName, caKeyName, certName, keyName, clientCertName, clientKeyName}
+	for _, key := range requiredKeys {
+		if _, ok := secret.Data[key]; !ok {
+			return true
+		}
+	}
+
+	// Validate CA certificate
+	if !r.validCACert(secret.Data[caCertName], secret.Data[caKeyName]) {
+		return true
+	}
+
+	// Validate server certificate
+	dnsNames := r.getProviderDNSNames(config)
+	if !r.validProviderCert(secret.Data[caCertName], secret.Data[certName], secret.Data[keyName], dnsNames) {
+		return true
+	}
+
+	// Validate client certificate
+	if !r.validProviderClientCert(secret.Data[caCertName], secret.Data[clientCertName], secret.Data[clientKeyName]) {
+		return true
+	}
+
+	return false
+}
+
+// refreshProviderCerts generates new certificates for the provider.
+func (r *ProviderCertReconciler) refreshProviderCerts(secret *corev1.Secret, config *ProviderCertConfig) error {
+	now := time.Now()
+	begin := now.Add(-1 * time.Hour)
+	end := now.Add(certValidityDuration)
+
+	// Check if we need to generate a new CA or reuse existing
+	var caArtifacts *KeyPairArtifacts
+	var err error
+
+	if secret.Data != nil && r.validCACert(secret.Data[caCertName], secret.Data[caKeyName]) {
+		// Reuse existing CA
+		caArtifacts, err = buildArtifactsFromSecret(secret)
+		if err != nil {
+			return fmt.Errorf("failed to load existing CA: %w", err)
+		}
+	} else {
+		// Generate new CA
+		caArtifacts, err = r.createCACert(begin, end)
+		if err != nil {
+			return fmt.Errorf("failed to create CA certificate: %w", err)
+		}
+	}
+
+	// Generate server certificate
+	serverCert, serverKey, err := r.createProviderServerCert(caArtifacts, config, begin, end)
+	if err != nil {
+		return fmt.Errorf("failed to create server certificate: %w", err)
+	}
+
+	// Generate client certificate
+	clientCert, clientKey, err := r.createProviderClientCert(caArtifacts, begin, end)
+	if err != nil {
+		return fmt.Errorf("failed to create client certificate: %w", err)
+	}
+
+	// Populate secret
+	if secret.Data == nil {
+		secret.Data = make(map[string][]byte)
+	}
+	secret.Data[caCertName] = caArtifacts.CertPEM
+	secret.Data[caKeyName] = caArtifacts.KeyPEM
+	secret.Data[certName] = serverCert
+	secret.Data[keyName] = serverKey
+	secret.Data[clientCertName] = clientCert
+	secret.Data[clientKeyName] = clientKey
+
+	return nil
+}
+
+// createCACert creates a new CA certificate.
+func (r *ProviderCertReconciler) createCACert(begin, end time.Time) (*KeyPairArtifacts, error) {
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(0),
+		Subject: pkix.Name{
+			CommonName:   r.CAName,
+			Organization: []string{r.CAOrganization},
+		},
+		DNSNames:              []string{r.CAName},
+		NotBefore:             begin,
+		NotAfter:              end,
+		KeyUsage:              x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+	}
+
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, fmt.Errorf(errGeneratePrivateKey, err)
+	}
+
+	certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create certificate: %w", err)
+	}
+
+	cert, err := x509.ParseCertificate(certDER)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse certificate: %w", err)
+	}
+
+	certPEM := pem.EncodeToMemory(&pem.Block{
+		Type:  "CERTIFICATE",
+		Bytes: certDER,
+	})
+
+	keyPEM := pem.EncodeToMemory(&pem.Block{
+		Type:  "RSA PRIVATE KEY",
+		Bytes: x509.MarshalPKCS1PrivateKey(key),
+	})
+
+	return &KeyPairArtifacts{
+		Cert:    cert,
+		Key:     key,
+		CertPEM: certPEM,
+		KeyPEM:  keyPEM,
+	}, nil
+}
+
+// createProviderServerCert generates a server certificate for the provider.
+func (r *ProviderCertReconciler) createProviderServerCert(
+	caArtifacts *KeyPairArtifacts,
+	config *ProviderCertConfig,
+	begin, end time.Time,
+) ([]byte, []byte, error) {
+	dnsNames := r.getProviderDNSNames(config)
+
+	// Create certificate template
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(time.Now().UnixNano()),
+		Subject: pkix.Name{
+			CommonName:   "external-secrets-provider",
+			Organization: []string{r.CAOrganization},
+		},
+		DNSNames:    dnsNames,
+		NotBefore:   begin,
+		NotAfter:    end,
+		KeyUsage:    x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+	}
+
+	// Generate private key
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, nil, fmt.Errorf(errGeneratePrivateKey, err)
+	}
+
+	// Create certificate
+	certDER, err := x509.CreateCertificate(rand.Reader, template, caArtifacts.Cert, &key.PublicKey, caArtifacts.Key)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to create certificate: %w", err)
+	}
+
+	// Encode certificate
+	certPEM := pem.EncodeToMemory(&pem.Block{
+		Type:  "CERTIFICATE",
+		Bytes: certDER,
+	})
+
+	// Encode private key
+	keyPEM := pem.EncodeToMemory(&pem.Block{
+		Type:  "RSA PRIVATE KEY",
+		Bytes: x509.MarshalPKCS1PrivateKey(key),
+	})
+
+	return certPEM, keyPEM, nil
+}
+
+// createProviderClientCert generates a client certificate for the ESO controller.
+func (r *ProviderCertReconciler) createProviderClientCert(
+	caArtifacts *KeyPairArtifacts,
+	begin, end time.Time,
+) ([]byte, []byte, error) {
+	// Create certificate template
+	template := &x509.Certificate{
+		SerialNumber: big.NewInt(time.Now().UnixNano()),
+		Subject: pkix.Name{
+			CommonName:   "external-secrets-controller",
+			Organization: []string{r.CAOrganization},
+		},
+		NotBefore:   begin,
+		NotAfter:    end,
+		KeyUsage:    x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+		ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
+	}
+
+	// Generate private key
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, nil, fmt.Errorf(errGeneratePrivateKey, err)
+	}
+
+	// Create certificate
+	certDER, err := x509.CreateCertificate(rand.Reader, template, caArtifacts.Cert, &key.PublicKey, caArtifacts.Key)
+	if err != nil {
+		return nil, nil, fmt.Errorf("failed to create certificate: %w", err)
+	}
+
+	// Encode certificate
+	certPEM := pem.EncodeToMemory(&pem.Block{
+		Type:  "CERTIFICATE",
+		Bytes: certDER,
+	})
+
+	// Encode private key
+	keyPEM := pem.EncodeToMemory(&pem.Block{
+		Type:  "RSA PRIVATE KEY",
+		Bytes: x509.MarshalPKCS1PrivateKey(key),
+	})
+
+	return certPEM, keyPEM, nil
+}
+
+// getProviderDNSNames returns the DNS names for the provider service.
+func (r *ProviderCertReconciler) getProviderDNSNames(config *ProviderCertConfig) []string {
+	dnsNames := make([]string, 0, len(config.ServiceNames)*4)
+	for _, serviceName := range config.ServiceNames {
+		dnsNames = append(dnsNames,
+			serviceName,
+			fmt.Sprintf("%s.%s", serviceName, config.Namespace),
+			fmt.Sprintf("%s.%s.svc", serviceName, config.Namespace),
+			fmt.Sprintf("%s.%s.svc.cluster.local", serviceName, config.Namespace),
+		)
+	}
+	return dnsNames
+}
+
+// validCACert validates a CA certificate.
+func (r *ProviderCertReconciler) validCACert(caCert, caKey []byte) bool {
+	if len(caCert) == 0 || len(caKey) == 0 {
+		return false
+	}
+
+	// Parse CA certificate
+	caDer, _ := pem.Decode(caCert)
+	if caDer == nil {
+		return false
+	}
+	caCertParsed, err := x509.ParseCertificate(caDer.Bytes)
+	if err != nil {
+		return false
+	}
+
+	// Check if CA is still valid with lookahead
+	if time.Now().After(lookaheadTime()) && lookaheadTime().After(caCertParsed.NotAfter) {
+		return false
+	}
+
+	return true
+}
+
+// validProviderCert validates a provider server certificate.
+func (r *ProviderCertReconciler) validProviderCert(caCert, cert, key []byte, dnsNames []string) bool {
+	if len(caCert) == 0 || len(cert) == 0 || len(key) == 0 {
+		return false
+	}
+
+	// Parse CA certificate
+	pool := x509.NewCertPool()
+	caDer, _ := pem.Decode(caCert)
+	if caDer == nil {
+		return false
+	}
+	caCertParsed, err := x509.ParseCertificate(caDer.Bytes)
+	if err != nil {
+		return false
+	}
+	pool.AddCert(caCertParsed)
+
+	// Parse server certificate
+	certDer, _ := pem.Decode(cert)
+	if certDer == nil {
+		return false
+	}
+	certParsed, err := x509.ParseCertificate(certDer.Bytes)
+	if err != nil {
+		return false
+	}
+
+	// Verify certificate is signed by CA
+	opts := x509.VerifyOptions{
+		Roots:       pool,
+		CurrentTime: lookaheadTime(),
+		KeyUsages:   []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+	}
+
+	if _, err := certParsed.Verify(opts); err != nil {
+		return false
+	}
+
+	// Check DNS names are present
+	for _, dnsName := range dnsNames {
+		if !slices.Contains(certParsed.DNSNames, dnsName) {
+			return false
+		}
+	}
+
+	return true
+}
+
+// validProviderClientCert validates a provider client certificate.
+func (r *ProviderCertReconciler) validProviderClientCert(caCert, cert, key []byte) bool {
+	if len(caCert) == 0 || len(cert) == 0 || len(key) == 0 {
+		return false
+	}
+
+	// Parse CA certificate
+	pool := x509.NewCertPool()
+	caDer, _ := pem.Decode(caCert)
+	if caDer == nil {
+		return false
+	}
+	caCertParsed, err := x509.ParseCertificate(caDer.Bytes)
+	if err != nil {
+		return false
+	}
+	pool.AddCert(caCertParsed)
+
+	// Parse client certificate
+	certDer, _ := pem.Decode(cert)
+	if certDer == nil {
+		return false
+	}
+	certParsed, err := x509.ParseCertificate(certDer.Bytes)
+	if err != nil {
+		return false
+	}
+
+	// Verify certificate is signed by CA
+	opts := x509.VerifyOptions{
+		Roots:       pool,
+		CurrentTime: lookaheadTime(),
+		KeyUsages:   []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
+	}
+
+	if _, err := certParsed.Verify(opts); err != nil {
+		return false
+	}
+
+	return true
+}
+
+// buildArtifactsFromSecret builds KeyPairArtifacts from a secret.
+func buildArtifactsFromSecret(secret *corev1.Secret) (*KeyPairArtifacts, error) {
+	caCertPEM := secret.Data[caCertName]
+	caKeyPEM := secret.Data[caKeyName]
+
+	caDer, _ := pem.Decode(caCertPEM)
+	if caDer == nil {
+		return nil, fmt.Errorf("failed to decode CA certificate")
+	}
+	caCert, err := x509.ParseCertificate(caDer.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse CA certificate: %w", err)
+	}
+
+	keyDer, _ := pem.Decode(caKeyPEM)
+	if keyDer == nil {
+		return nil, fmt.Errorf("failed to decode CA key")
+	}
+	caKey, err := x509.ParsePKCS1PrivateKey(keyDer.Bytes)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse CA key: %w", err)
+	}
+
+	return &KeyPairArtifacts{
+		Cert:    caCert,
+		Key:     caKey,
+		CertPEM: caCertPEM,
+		KeyPEM:  caKeyPEM,
+	}, nil
+}
+
+// lookaheadTime returns the current time plus a lookahead duration
+// to ensure certificates are refreshed before expiration.
+func lookaheadTime() time.Time {
+	return time.Now().Add(365 * 24 * time.Hour) // 1 year lookahead
+}

+ 89 - 0
pkg/controllers/providercerts/runnable.go

@@ -0,0 +1,89 @@
+/*
+Copyright © The ESO Authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package providercerts
+
+import (
+	"context"
+	"time"
+
+	"github.com/go-logr/logr"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// ProviderCertReconciler periodically reconciles provider certificates.
+type ProviderCertReconciler struct {
+	client.Client
+	Log             logr.Logger
+	ProviderConfig  *ProviderCertConfig
+	RequeueInterval time.Duration
+	CAName          string
+	CAOrganization  string
+	leaderElected   <-chan struct{}
+}
+
+// New creates a new ProviderCertReconciler.
+func New(
+	k8sClient client.Client,
+	logger logr.Logger,
+	config *ProviderCertConfig,
+	interval time.Duration,
+	leaderChan <-chan struct{},
+) *ProviderCertReconciler {
+	return &ProviderCertReconciler{
+		Client:          k8sClient,
+		Log:             logger,
+		ProviderConfig:  config,
+		RequeueInterval: interval,
+		CAName:          "external-secrets",
+		CAOrganization:  "external-secrets",
+		leaderElected:   leaderChan,
+	}
+}
+
+// Start implements manager.Runnable.
+func (r *ProviderCertReconciler) Start(ctx context.Context) error {
+	r.Log.Info("starting provider certificate reconciler")
+
+	// Wait for leader election
+	select {
+	case <-ctx.Done():
+		return nil
+	case <-r.leaderElected:
+		r.Log.Info("leader elected, starting provider certificate reconciliation")
+	}
+
+	// Run initial reconciliation
+	if err := r.ReconcileProviderCert(ctx, r.ProviderConfig); err != nil {
+		r.Log.Error(err, "failed to reconcile provider certificates")
+	}
+
+	// Start periodic reconciliation
+	ticker := time.NewTicker(r.RequeueInterval)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			r.Log.Info("stopping provider certificate reconciler")
+			return nil
+		case <-ticker.C:
+			if err := r.ReconcileProviderCert(ctx, r.ProviderConfig); err != nil {
+				r.Log.Error(err, "failed to reconcile provider certificates")
+			}
+		}
+	}
+}