diff --git a/api/v1alpha1/certificateauthority_types.go b/api/v1alpha1/certificateauthority_types.go index a562865..6deff78 100644 --- a/api/v1alpha1/certificateauthority_types.go +++ b/api/v1alpha1/certificateauthority_types.go @@ -32,6 +32,7 @@ type CertificateAuthorityList struct { } // CertificateAuthoritySpec defines the desired state of CertificateAuthority. +// +kubebuilder:validation:XValidation:rule="!(has(self.external) && has(self.storage) && (self.storage.size != '' || self.storage.storageClass != ''))",message="external and storage are mutually exclusive" type CertificateAuthoritySpec struct { // TTL is the CA certificate TTL as a duration string. // Supported units: s (seconds), m (minutes), h (hours), d (days), y (years). @@ -87,16 +88,46 @@ type CertificateAuthoritySpec struct { // IntermediateCA configures an intermediate CA setup. // +optional IntermediateCA IntermediateCASpec `json:"intermediateCA,omitempty"` + + // External configures an external (out-of-cluster) CA. + // When set, the operator skips CA setup Job and PVC creation. + // Mutually exclusive with Storage. + // +optional + External *ExternalCASpec `json:"external,omitempty"` +} + +// ExternalCASpec defines connection settings for an external Puppet/OpenVox CA. +type ExternalCASpec struct { + // URL is the base URL of the external CA HTTP API. + // Example: "https://puppet-ca.example.com:8140" + // +kubebuilder:validation:Pattern=`^https?://` + URL string `json:"url"` + + // CASecretRef references a Secret containing the CA certificate. + // The Secret must have a key "ca_crt.pem" with the PEM-encoded CA certificate. + // +optional + CASecretRef string `json:"caSecretRef,omitempty"` + + // TLSSecretRef references a Secret with client TLS credentials for mTLS to the CA. + // The Secret must contain "tls.crt" and "tls.key". + // +optional + TLSSecretRef string `json:"tlsSecretRef,omitempty"` + + // InsecureSkipVerify disables TLS verification for the external CA connection. + // Not recommended for production use. + // +optional + InsecureSkipVerify bool `json:"insecureSkipVerify,omitempty"` } // CertificateAuthorityPhase represents the current lifecycle phase of a CertificateAuthority. -// +kubebuilder:validation:Enum=Pending;Initializing;Ready;Error +// +kubebuilder:validation:Enum=Pending;Initializing;Ready;External;Error type CertificateAuthorityPhase string const ( CertificateAuthorityPhasePending CertificateAuthorityPhase = "Pending" CertificateAuthorityPhaseInitializing CertificateAuthorityPhase = "Initializing" CertificateAuthorityPhaseReady CertificateAuthorityPhase = "Ready" + CertificateAuthorityPhaseExternal CertificateAuthorityPhase = "External" CertificateAuthorityPhaseError CertificateAuthorityPhase = "Error" ) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 90a9259..fcbccf7 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -210,6 +210,11 @@ func (in *CertificateAuthoritySpec) DeepCopyInto(out *CertificateAuthoritySpec) out.Storage = in.Storage in.Resources.DeepCopyInto(&out.Resources) out.IntermediateCA = in.IntermediateCA + if in.External != nil { + in, out := &in.External, &out.External + *out = new(ExternalCASpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CertificateAuthoritySpec. @@ -471,6 +476,21 @@ func (in *ConfigStatus) DeepCopy() *ConfigStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExternalCASpec) DeepCopyInto(out *ExternalCASpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExternalCASpec. +func (in *ExternalCASpec) DeepCopy() *ExternalCASpec { + if in == nil { + return nil + } + out := new(ExternalCASpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GatewayReference) DeepCopyInto(out *GatewayReference) { *out = *in diff --git a/config/samples/certificateauthority-external.yaml b/config/samples/certificateauthority-external.yaml new file mode 100644 index 0000000..d8c0735 --- /dev/null +++ b/config/samples/certificateauthority-external.yaml @@ -0,0 +1,37 @@ +apiVersion: openvox.voxpupuli.org/v1alpha1 +kind: CertificateAuthority +metadata: + name: production-external +spec: + external: + url: "https://puppet-ca.example.com:8140" + caSecretRef: puppet-ca-cert + tlsSecretRef: puppet-client-tls +--- +# Secret with CA certificate +apiVersion: v1 +kind: Secret +metadata: + name: puppet-ca-cert +type: Opaque +stringData: + ca_crt.pem: | + -----BEGIN CERTIFICATE----- + ... your CA certificate ... + -----END CERTIFICATE----- +--- +# Secret with client TLS credentials for mTLS +apiVersion: v1 +kind: Secret +metadata: + name: puppet-client-tls +type: Opaque +stringData: + tls.crt: | + -----BEGIN CERTIFICATE----- + ... your client certificate ... + -----END CERTIFICATE----- + tls.key: | + -----BEGIN RSA PRIVATE KEY----- + ... your client private key ... + -----END RSA PRIVATE KEY----- diff --git a/docs/guides/ca-import.md b/docs/guides/ca-import.md new file mode 100644 index 0000000..47d13f4 --- /dev/null +++ b/docs/guides/ca-import.md @@ -0,0 +1,47 @@ +# CA Import Guide + +If you have an existing Puppet/OpenVox CA and want to migrate to the operator-managed CA, +you can seed the CA PVC manually. This is a one-time operation. + +## Steps + +1. Create the CertificateAuthority resource without applying it yet +2. Create the PVC manually with the correct size +3. Run a Kubernetes Job to copy CA data from a Secret into the PVC: + - Mount your CA data as a Secret + - Copy to the expected paths under `/etc/puppetlabs/puppetserver/ca/` +4. Create the required Secrets manually: + - `{name}-ca`: contains `ca_crt.pem` + - `{name}-ca-key`: contains `ca_key.pem` + - `{name}-ca-crl`: contains `ca_crl.pem` +5. Patch the CertificateAuthority status to Ready + +## External CA Alternative + +Instead of importing the CA data, you can use an External CA (`spec.external`) to keep the +CA running outside the cluster. The operator will connect to the external CA's HTTP API for +certificate signing operations. + +See the [External CA sample](../../config/samples/certificateauthority-external.yaml) for +a complete example. + +### External CA Configuration + +```yaml +apiVersion: openvox.voxpupuli.org/v1alpha1 +kind: CertificateAuthority +metadata: + name: my-ca +spec: + external: + url: "https://puppet-ca.example.com:8140" + caSecretRef: puppet-ca-cert # Secret with ca_crt.pem + tlsSecretRef: puppet-client-tls # Secret with tls.crt and tls.key for mTLS +``` + +When `spec.external` is set: + +- The operator skips PVC creation and CA setup Job +- Certificate signing requests are sent to the external CA URL +- CRL refresh is not performed (managed externally) +- The CA enters the `External` phase instead of `Ready` diff --git a/enc b/enc new file mode 100755 index 0000000..fbfa922 Binary files /dev/null and b/enc differ diff --git a/internal/controller/certificate_controller.go b/internal/controller/certificate_controller.go index fe03522..67692ff 100644 --- a/internal/controller/certificate_controller.go +++ b/internal/controller/certificate_controller.go @@ -70,8 +70,8 @@ func (r *CertificateReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, err } - // Wait for CA to be ready - if ca.Status.Phase != openvoxv1alpha1.CertificateAuthorityPhaseReady { + // Wait for CA to be ready (internal or external) + if ca.Status.Phase != openvoxv1alpha1.CertificateAuthorityPhaseReady && ca.Status.Phase != openvoxv1alpha1.CertificateAuthorityPhaseExternal { logger.Info("waiting for CertificateAuthority to be ready", "ca", ca.Name, "phase", ca.Status.Phase) cert.Status.Phase = openvoxv1alpha1.CertificatePhasePending if statusErr := r.Status().Update(ctx, cert); statusErr != nil { @@ -126,10 +126,16 @@ func (r *CertificateReconciler) SetupWithManager(mgr ctrl.Manager) error { func (r *CertificateReconciler) reconcileCertSigning(ctx context.Context, cert *openvoxv1alpha1.Certificate, ca *openvoxv1alpha1.CertificateAuthority) (ctrl.Result, error) { logger := log.FromContext(ctx) - caServiceName := findCAServiceName(ctx, r.Client, ca, cert.Namespace) - if caServiceName == "" { - logger.Info("waiting for CA server to become available") - return ctrl.Result{RequeueAfter: RequeueIntervalMedium}, nil + var caBaseURL string + if ca.Spec.External != nil { + caBaseURL = ca.Spec.External.URL + } else { + caServiceName := findCAServiceName(ctx, r.Client, ca, cert.Namespace) + if caServiceName == "" { + logger.Info("waiting for CA server to become available") + return ctrl.Result{RequeueAfter: RequeueIntervalMedium}, nil + } + caBaseURL = fmt.Sprintf("https://%s.%s.svc:8140", caServiceName, cert.Namespace) } cert.Status.Phase = openvoxv1alpha1.CertificatePhaseRequesting @@ -137,7 +143,7 @@ func (r *CertificateReconciler) reconcileCertSigning(ctx context.Context, cert * logger.Error(statusErr, "failed to update Certificate status", "name", cert.Name) } - result, err := r.signCertificate(ctx, cert, ca, caServiceName, cert.Namespace) + result, err := r.signCertificate(ctx, cert, ca, caBaseURL, cert.Namespace) if err != nil { logger.Error(err, "certificate signing failed, will retry") cert.Status.Phase = openvoxv1alpha1.CertificatePhaseError diff --git a/internal/controller/certificate_signing.go b/internal/controller/certificate_signing.go index cd916a2..85f21a8 100644 --- a/internal/controller/certificate_signing.go +++ b/internal/controller/certificate_signing.go @@ -79,10 +79,79 @@ func (r *CertificateReconciler) getCAPublicCert(ctx context.Context, ca *openvox return certPEM, nil } +// caHTTPClientForCA returns an HTTP client appropriate for the CA type. +// For external CAs with TLS credentials, it builds an mTLS client. +// For internal CAs, it returns the default insecure client. +func (r *CertificateReconciler) caHTTPClientForCA(ctx context.Context, ca *openvoxv1alpha1.CertificateAuthority, namespace string) *http.Client { + if ca.Spec.External == nil { + // Internal CA: use CA cert from Secret for TLS verification + caCertPEM, err := r.getCAPublicCert(ctx, ca, namespace) + if err != nil { + log.FromContext(ctx).Error(err, "failed to load CA cert, falling back to insecure client") + return &http.Client{Timeout: HTTPClientTimeout, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} //nolint:gosec + } + client, err := caHTTPClient(caCertPEM) + if err != nil { + log.FromContext(ctx).Error(err, "failed to build CA HTTP client, falling back to insecure") + return &http.Client{Timeout: HTTPClientTimeout, Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} //nolint:gosec + } + return client + } + + logger := log.FromContext(ctx) + tlsCfg := &tls.Config{} //nolint:gosec // configured below + + // Load CA certificate for verification + if ca.Spec.External.CASecretRef != "" { + caSecret := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: ca.Spec.External.CASecretRef, Namespace: namespace}, caSecret); err == nil { + if caCertPEM, ok := caSecret.Data["ca_crt.pem"]; ok { + pool := x509.NewCertPool() + if pool.AppendCertsFromPEM(caCertPEM) { + tlsCfg.RootCAs = pool + } + } + } else { + logger.Error(err, "failed to read external CA Secret", "secret", ca.Spec.External.CASecretRef) + } + } + + // Load client TLS credentials for mTLS + if ca.Spec.External.TLSSecretRef != "" { + tlsSecret := &corev1.Secret{} + if err := r.Get(ctx, types.NamespacedName{Name: ca.Spec.External.TLSSecretRef, Namespace: namespace}, tlsSecret); err == nil { + certPEM := tlsSecret.Data["tls.crt"] + keyPEM := tlsSecret.Data["tls.key"] + if len(certPEM) > 0 && len(keyPEM) > 0 { + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err == nil { + tlsCfg.Certificates = []tls.Certificate{cert} + } else { + logger.Error(err, "failed to load client TLS credentials", "secret", ca.Spec.External.TLSSecretRef) + } + } + } else { + logger.Error(err, "failed to read TLS Secret", "secret", ca.Spec.External.TLSSecretRef) + } + } + + if ca.Spec.External.InsecureSkipVerify { + tlsCfg.InsecureSkipVerify = true //nolint:gosec // user explicitly opted in + } else if tlsCfg.RootCAs == nil { + // No CA cert and no insecureSkipVerify: fall back to system roots + // (which works if the external CA uses a publicly trusted cert) + } + + return &http.Client{ + Timeout: HTTPClientTimeout, + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + } +} + // submitCSR generates an RSA key (or reuses an existing one from a pending Secret), // submits the CSR to the Puppet CA, and stores the private key in a pending Secret. // Returns the pending Secret name. -func (r *CertificateReconciler) submitCSR(ctx context.Context, cert *openvoxv1alpha1.Certificate, ca *openvoxv1alpha1.CertificateAuthority, caServiceName, namespace string) (ctrl.Result, error) { +func (r *CertificateReconciler) submitCSR(ctx context.Context, cert *openvoxv1alpha1.Certificate, ca *openvoxv1alpha1.CertificateAuthority, caBaseURL, namespace string) (ctrl.Result, error) { logger := log.FromContext(ctx) certname := cert.Spec.Certname @@ -151,15 +220,7 @@ func (r *CertificateReconciler) submitCSR(ctx context.Context, cert *openvoxv1al } csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}) - caCertPEM, err := r.getCAPublicCert(ctx, ca, namespace) - if err != nil { - return ctrl.Result{RequeueAfter: RequeueIntervalShort}, fmt.Errorf("loading CA certificate: %w", err) - } - httpClient, err := caHTTPClient(caCertPEM) - if err != nil { - return ctrl.Result{}, fmt.Errorf("creating CA HTTP client: %w", err) - } - caBaseURL := fmt.Sprintf("https://%s.%s.svc:8140", caServiceName, namespace) + httpClient := r.caHTTPClientForCA(ctx, ca, namespace) csrURL := fmt.Sprintf("%s/puppet-ca/v1/certificate_request/%s?environment=production", caBaseURL, certname) logger.Info("submitting CSR to CA", "url", csrURL, "certname", certname) @@ -192,21 +253,13 @@ func (r *CertificateReconciler) submitCSR(ctx context.Context, cert *openvoxv1al } // fetchSignedCert checks if the CA has signed the certificate. Returns the PEM cert or nil. -func (r *CertificateReconciler) fetchSignedCert(ctx context.Context, cert *openvoxv1alpha1.Certificate, ca *openvoxv1alpha1.CertificateAuthority, caServiceName, namespace string) ([]byte, error) { +func (r *CertificateReconciler) fetchSignedCert(ctx context.Context, cert *openvoxv1alpha1.Certificate, ca *openvoxv1alpha1.CertificateAuthority, caBaseURL, namespace string) ([]byte, error) { certname := cert.Spec.Certname if certname == "" { certname = "puppet" } - caCertPEM, err := r.getCAPublicCert(ctx, ca, namespace) - if err != nil { - return nil, fmt.Errorf("loading CA certificate: %w", err) - } - httpClient, err := caHTTPClient(caCertPEM) - if err != nil { - return nil, fmt.Errorf("creating CA HTTP client: %w", err) - } - caBaseURL := fmt.Sprintf("https://%s.%s.svc:8140", caServiceName, namespace) + httpClient := r.caHTTPClientForCA(ctx, ca, namespace) certURL := fmt.Sprintf("%s/puppet-ca/v1/certificate/%s?environment=production", caBaseURL, certname) req, err := http.NewRequestWithContext(ctx, http.MethodGet, certURL, nil) @@ -239,16 +292,16 @@ func (r *CertificateReconciler) fetchSignedCert(ctx context.Context, cert *openv // signCertificate is the non-blocking orchestrator. It submits the CSR (if not already done), // checks for the signed cert, and returns RequeueAfter if still waiting. -func (r *CertificateReconciler) signCertificate(ctx context.Context, cert *openvoxv1alpha1.Certificate, ca *openvoxv1alpha1.CertificateAuthority, caServiceName, namespace string) (ctrl.Result, error) { +func (r *CertificateReconciler) signCertificate(ctx context.Context, cert *openvoxv1alpha1.Certificate, ca *openvoxv1alpha1.CertificateAuthority, caBaseURL, namespace string) (ctrl.Result, error) { logger := log.FromContext(ctx) // Step 1: Ensure CSR is submitted and key is persisted - if result, err := r.submitCSR(ctx, cert, ca, caServiceName, namespace); err != nil { + if result, err := r.submitCSR(ctx, cert, ca, caBaseURL, namespace); err != nil { return result, err } // Step 2: Check if cert is signed (non-blocking, single attempt) - signedCertPEM, err := r.fetchSignedCert(ctx, cert, ca, caServiceName, namespace) + signedCertPEM, err := r.fetchSignedCert(ctx, cert, ca, caBaseURL, namespace) if err != nil { logger.Info("failed to fetch signed cert, will retry", "error", err) return ctrl.Result{RequeueAfter: RequeueIntervalMedium}, nil diff --git a/internal/controller/certificateauthority_controller.go b/internal/controller/certificateauthority_controller.go index a5c615b..bc70406 100644 --- a/internal/controller/certificateauthority_controller.go +++ b/internal/controller/certificateauthority_controller.go @@ -65,6 +65,11 @@ func (r *CertificateAuthorityReconciler) Reconcile(ctx context.Context, req ctrl } } + // External CA: skip internal setup, just validate and mark ready + if ca.Spec.External != nil { + return r.reconcileExternalCA(ctx, ca) + } + // Resolve Config referencing this CA cfg := r.findConfigForCA(ctx, ca) if cfg == nil { @@ -135,6 +140,60 @@ func (r *CertificateAuthorityReconciler) Reconcile(ctx context.Context, req ctrl return crlResult, nil } +// reconcileExternalCA handles CertificateAuthority resources with spec.external set. +// It skips PVC/Job creation and marks the CA as ready using the external CA's Secret reference. +func (r *CertificateAuthorityReconciler) reconcileExternalCA(ctx context.Context, ca *openvoxv1alpha1.CertificateAuthority) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + // Validate that the referenced CA Secret exists (if specified) + caSecretName := "" + if ca.Spec.External.CASecretRef != "" { + if !isSecretReady(ctx, r.Client, ca.Spec.External.CASecretRef, ca.Namespace, "ca_crt.pem") { + logger.Info("waiting for external CA Secret", "secret", ca.Spec.External.CASecretRef) + ca.Status.Phase = openvoxv1alpha1.CertificateAuthorityPhasePending + meta.SetStatusCondition(&ca.Status.Conditions, metav1.Condition{ + Type: openvoxv1alpha1.ConditionCAReady, + Status: metav1.ConditionFalse, + Reason: "CASecretNotFound", + Message: fmt.Sprintf("Secret %s not found or missing ca_crt.pem", ca.Spec.External.CASecretRef), + LastTransitionTime: metav1.Now(), + }) + if err := r.Status().Update(ctx, ca); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: RequeueIntervalMedium}, nil + } + caSecretName = ca.Spec.External.CASecretRef + } + + wasReady := ca.Status.Phase == openvoxv1alpha1.CertificateAuthorityPhaseExternal + ca.Status.Phase = openvoxv1alpha1.CertificateAuthorityPhaseExternal + ca.Status.CASecretName = caSecretName + if caSecretName != "" { + ca.Status.NotAfter = r.extractCANotAfter(ctx, caSecretName, ca.Namespace) + } else { + ca.Status.NotAfter = nil + } + meta.SetStatusCondition(&ca.Status.Conditions, metav1.Condition{ + Type: openvoxv1alpha1.ConditionCAReady, + Status: metav1.ConditionTrue, + Reason: "ExternalCA", + Message: fmt.Sprintf("External CA configured at %s", ca.Spec.External.URL), + LastTransitionTime: metav1.Now(), + }) + + if err := r.Status().Update(ctx, ca); err != nil { + return ctrl.Result{}, err + } + + if !wasReady { + r.Recorder.Eventf(ca, nil, corev1.EventTypeNormal, EventReasonCAInitialized, "Reconcile", "External CA configured at %s", ca.Spec.External.URL) + } + + // No CRL refresh for external CA -- managed externally + return ctrl.Result{}, nil +} + // findConfigForCA returns the first Config in the same namespace whose authorityRef matches this CA. func (r *CertificateAuthorityReconciler) findConfigForCA(ctx context.Context, ca *openvoxv1alpha1.CertificateAuthority) *openvoxv1alpha1.Config { cfgList := &openvoxv1alpha1.ConfigList{}