Skip to content
Closed
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
33 changes: 32 additions & 1 deletion api/v1alpha1/certificateauthority_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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"
)

Expand Down
20 changes: 20 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions config/samples/certificateauthority-external.yaml
Original file line number Diff line number Diff line change
@@ -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-----
47 changes: 47 additions & 0 deletions docs/guides/ca-import.md
Original file line number Diff line number Diff line change
@@ -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`
Binary file added enc
Binary file not shown.
20 changes: 13 additions & 7 deletions internal/controller/certificate_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -126,18 +126,24 @@ 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
if statusErr := r.Status().Update(ctx, cert); statusErr != nil {
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
Expand Down
99 changes: 76 additions & 23 deletions internal/controller/certificate_signing.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,79 @@
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 {

Check failure on line 140 in internal/controller/certificate_signing.go

View workflow job for this annotation

GitHub Actions / go / lint

SA9003: empty branch (staticcheck)
// 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
Expand Down Expand Up @@ -151,15 +220,7 @@
}
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)
Expand Down Expand Up @@ -192,21 +253,13 @@
}

// 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)
Expand Down Expand Up @@ -239,16 +292,16 @@

// 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
Expand Down
Loading
Loading