From 8a848f17600a151b77dab9405ed2f70adf79950b Mon Sep 17 00:00:00 2001 From: Romain Forlot Date: Fri, 4 Sep 2026 14:51:06 +0200 Subject: [PATCH 1/4] feat(cloudflare): add leaf-only option to skip CA bundling Some CA bundles are rejected by Cloudflare's custom_certificates trust store validation ("certificate chain cannot be bundled"). Add a per-secret leaf-only annotation to upload only the leaf certificate instead of the full chain. --- stores/cloudflare/cloudflare.go | 27 +++++++++++++-- stores/cloudflare/cloudflare_test.go | 49 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/stores/cloudflare/cloudflare.go b/stores/cloudflare/cloudflare.go index 44bee8b..9375b7f 100644 --- a/stores/cloudflare/cloudflare.go +++ b/stores/cloudflare/cloudflare.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "github.com/cloudflare/cloudflare-go/v5" @@ -21,6 +22,10 @@ type CloudflareStore struct { ApiToken string ZoneId string CertId string + // LeafOnly, when true, uploads only the leaf certificate to Cloudflare + // instead of the full chain (leaf + CA). Some CA bundles are rejected by + // Cloudflare's custom_certificates trust store validation. + LeafOnly bool } func (s *CloudflareStore) GetApiToken(ctx context.Context) error { @@ -53,6 +58,13 @@ func (s *CloudflareStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error if c.Config["cert-id"] != "" { s.CertId = c.Config["cert-id"] } + if c.Config["leaf-only"] != "" { + leafOnly, err := strconv.ParseBool(c.Config["leaf-only"]) + if err != nil { + return fmt.Errorf("invalid leaf-only value %q: %w", c.Config["leaf-only"], err) + } + s.LeafOnly = leafOnly + } // if secret name is in the format of "namespace/secretname" then parse it if strings.Contains(s.SecretName, "/") { s.SecretNamespace = strings.Split(s.SecretName, "/")[0] @@ -67,6 +79,16 @@ func (s *CloudflareStore) setDefaultSecretNamespace(namespace string) { } } +// certificatePayload returns the certificate bytes to upload to Cloudflare. +// When LeafOnly is set, the CA certificate is omitted so only the leaf +// certificate is sent, avoiding Cloudflare trust-store bundling rejections. +func (s *CloudflareStore) certificatePayload(c *tlssecret.Certificate) []byte { + if s.LeafOnly { + return c.Certificate + } + return c.FullChain() +} + func (s *CloudflareStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { s.setDefaultSecretNamespace(c.Namespace) l := log.WithFields(log.Fields{ @@ -89,11 +111,12 @@ func (s *CloudflareStore) Sync(c *tlssecret.Certificate) (map[string]string, err origCertId := s.CertId var cert *custom_certificates.CustomCertificate var err error + certPayload := s.certificatePayload(c) if s.CertId != "" { // Update existing certificate cert, err = client.CustomCertificates.Edit(ctx, s.CertId, custom_certificates.CustomCertificateEditParams{ ZoneID: cloudflare.F(s.ZoneId), - Certificate: cloudflare.F(string(c.FullChain())), + Certificate: cloudflare.F(string(certPayload)), PrivateKey: cloudflare.F(string(c.Key)), }) if err != nil { @@ -104,7 +127,7 @@ func (s *CloudflareStore) Sync(c *tlssecret.Certificate) (map[string]string, err // Create new certificate cert, err = client.CustomCertificates.New(ctx, custom_certificates.CustomCertificateNewParams{ ZoneID: cloudflare.F(s.ZoneId), - Certificate: cloudflare.F(string(c.FullChain())), + Certificate: cloudflare.F(string(certPayload)), PrivateKey: cloudflare.F(string(c.Key)), }) if err != nil { diff --git a/stores/cloudflare/cloudflare_test.go b/stores/cloudflare/cloudflare_test.go index 59c3235..ad37980 100644 --- a/stores/cloudflare/cloudflare_test.go +++ b/stores/cloudflare/cloudflare_test.go @@ -64,6 +64,55 @@ func TestCloudflareFromConfigParsesNamespacedSecretName(t *testing.T) { assert.Equal(t, "cert", s.CertId) } +func TestCloudflareFromConfigParsesLeafOnly(t *testing.T) { + cases := []struct { + name string + value string + want bool + wantErr bool + }{ + {name: "true", value: "true", want: true}, + {name: "false", value: "false", want: false}, + {name: "unset defaults to false", value: "", want: false}, + {name: "invalid value errors", value: "yes", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := &CloudflareStore{} + cfg := map[string]string{"zone-id": "zone"} + if tc.value != "" { + cfg["leaf-only"] = tc.value + } + err := s.FromConfig(tlssecret.GenericSecretSyncConfig{Config: cfg}) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, s.LeafOnly) + }) + } +} + +func TestCloudflareCertificatePayload(t *testing.T) { + cert := &tlssecret.Certificate{ + Certificate: []byte("leaf"), + Ca: []byte("ca"), + } + + t.Run("full chain by default", func(t *testing.T) { + s := &CloudflareStore{} + assert.Equal(t, cert.FullChain(), s.certificatePayload(cert)) + assert.Contains(t, string(s.certificatePayload(cert)), "ca") + }) + + t.Run("leaf only when enabled", func(t *testing.T) { + s := &CloudflareStore{LeafOnly: true} + assert.Equal(t, cert.Certificate, s.certificatePayload(cert)) + assert.NotContains(t, string(s.certificatePayload(cert)), "ca") + }) +} + func TestCloudflareSetDefaultSecretNamespace(t *testing.T) { t.Run("defaults when empty", func(t *testing.T) { s := &CloudflareStore{} From 52d48cbf4438671a581e8e9a77672ad3fd995108 Mon Sep 17 00:00:00 2001 From: Romain Forlot Date: Fri, 4 Sep 2026 15:04:14 +0200 Subject: [PATCH 2/4] docs(cloudflare): document the leaf-only annotation --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index adad496..2149a3e 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Annotations: cert-manager-sync.lestak.sh/cloudflare-secret-name: "example-cloudflare-secret" # secret in same namespace which contains the cloudflare api token. If provided in format "namespace/secret-name", will look in that namespace for the secret cert-manager-sync.lestak.sh/cloudflare-zone-id: "example-zone-id" # cloudflare zone id cert-manager-sync.lestak.sh/cloudflare-cert-id: "" # will be auto-filled by operator for in-place renewals + cert-manager-sync.lestak.sh/cloudflare-leaf-only: "false" # when "true", uploads only the leaf certificate instead of the full chain (leaf + CA). Useful when Cloudflare rejects the CA bundle ("certificate chain cannot be bundled using Cloudflare's trust store"). ``` ### DigitalOcean @@ -565,6 +566,7 @@ metadata: cert-manager-sync.lestak.sh/cloudflare-secret-name: "example-cloudflare-secret" # secret in same namespace which contains the cloudflare api token. If provided in format "namespace/secret-name", will look in that namespace for the secret cert-manager-sync.lestak.sh/cloudflare-zone-id: "example-zone-id" # cloudflare zone id cert-manager-sync.lestak.sh/cloudflare-cert-id: "" # will be auto-filled by operator for in-place renewals + cert-manager-sync.lestak.sh/cloudflare-leaf-only: "false" # when "true", uploads only the leaf certificate instead of the full chain (leaf + CA). Useful when Cloudflare rejects the CA bundle ("certificate chain cannot be bundled using Cloudflare's trust store"). cert-manager-sync.lestak.sh/digitalocean-enabled: "true" # sync certificate to DigitalOcean cert-manager-sync.lestak.sh/digitalocean-secret-name: "example-digitalocean-secret" # secret in same namespace which contains the digitalocean api key. If provided in format "namespace/secret-name", will look in that namespace for the secret cert-manager-sync.lestak.sh/digitalocean-cert-name: "my-cert" # unique name to give your cert in DigitalOcean From d66d244fc59be7fac069023e070be62c31967344 Mon Sep 17 00:00:00 2001 From: Romain Forlot Date: Fri, 4 Sep 2026 17:39:33 +0200 Subject: [PATCH 3/4] feat(cloudflare): add origin-pull mode for Authenticated Origin Pulls The custom_certificates endpoint uploads edge certificates, which Cloudflare bundles against its public trust store. Syncing an origin mTLS client certificate issued by an internal CA there always fails with "certificate chain cannot be bundled using Cloudflare's trust store", whether or not the CA is included. Add a cloudflare-mode annotation selecting the target store. "origin-pull" uses the origin_tls_client_auth endpoint, which is the one meant for client certificates Cloudflare presents to the origin: it takes the leaf and the private key only, and accepts a private issuing CA. That endpoint has no update method, so a renewal uploads the new certificate and removes the one it replaced. Replaces the leaf-only flag, which could not fix this: a leaf issued by a private CA is rejected by custom_certificates all the same. --- README.md | 23 ++++- stores/cloudflare/cloudflare.go | 129 +++++++++++++++++++-------- stores/cloudflare/cloudflare_test.go | 35 ++------ 3 files changed, 120 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 2149a3e..24dc60d 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,28 @@ Annotations: cert-manager-sync.lestak.sh/cloudflare-secret-name: "example-cloudflare-secret" # secret in same namespace which contains the cloudflare api token. If provided in format "namespace/secret-name", will look in that namespace for the secret cert-manager-sync.lestak.sh/cloudflare-zone-id: "example-zone-id" # cloudflare zone id cert-manager-sync.lestak.sh/cloudflare-cert-id: "" # will be auto-filled by operator for in-place renewals - cert-manager-sync.lestak.sh/cloudflare-leaf-only: "false" # when "true", uploads only the leaf certificate instead of the full chain (leaf + CA). Useful when Cloudflare rejects the CA bundle ("certificate chain cannot be bundled using Cloudflare's trust store"). + cert-manager-sync.lestak.sh/cloudflare-mode: "custom-certificate" # "custom-certificate" (default) uploads an edge certificate served to visitors, which must chain to a publicly trusted CA. "origin-pull" uploads an Authenticated Origin Pulls client certificate that Cloudflare presents to your origin: only the leaf and the key are sent, and the issuing CA may be private. ``` +#### Choosing a mode + +The two modes target different Cloudflare certificate stores, and they are not +interchangeable: + +- `custom-certificate` (default) uploads an **edge certificate**, the one + Cloudflare serves to visitors. Cloudflare bundles it against its own trust + store, so it must chain to a publicly trusted CA. Syncing a certificate issued + by an internal CA here fails with `The certificate chain you uploaded cannot + be bundled using Cloudflare's trust store`. +- `origin-pull` uploads a **client certificate for Authenticated Origin Pulls**, + which Cloudflare presents to your origin so the origin can authenticate + incoming requests. Only the leaf certificate and the private key are sent — + Cloudflare rejects a CA certificate on this endpoint — and the issuing CA may + be private, which is the usual setup for origin mTLS. + +Authenticated Origin Pulls has no update endpoint, so a renewal uploads the new +certificate and then removes the one it replaced. + ### DigitalOcean Create a DigitalOcean API Key and create a kube secret containing this key. @@ -566,7 +585,7 @@ metadata: cert-manager-sync.lestak.sh/cloudflare-secret-name: "example-cloudflare-secret" # secret in same namespace which contains the cloudflare api token. If provided in format "namespace/secret-name", will look in that namespace for the secret cert-manager-sync.lestak.sh/cloudflare-zone-id: "example-zone-id" # cloudflare zone id cert-manager-sync.lestak.sh/cloudflare-cert-id: "" # will be auto-filled by operator for in-place renewals - cert-manager-sync.lestak.sh/cloudflare-leaf-only: "false" # when "true", uploads only the leaf certificate instead of the full chain (leaf + CA). Useful when Cloudflare rejects the CA bundle ("certificate chain cannot be bundled using Cloudflare's trust store"). + cert-manager-sync.lestak.sh/cloudflare-mode: "custom-certificate" # "custom-certificate" (default) uploads an edge certificate served to visitors, which must chain to a publicly trusted CA. "origin-pull" uploads an Authenticated Origin Pulls client certificate that Cloudflare presents to your origin: only the leaf and the key are sent, and the issuing CA may be private. cert-manager-sync.lestak.sh/digitalocean-enabled: "true" # sync certificate to DigitalOcean cert-manager-sync.lestak.sh/digitalocean-secret-name: "example-digitalocean-secret" # secret in same namespace which contains the digitalocean api key. If provided in format "namespace/secret-name", will look in that namespace for the secret cert-manager-sync.lestak.sh/digitalocean-cert-name: "my-cert" # unique name to give your cert in DigitalOcean diff --git a/stores/cloudflare/cloudflare.go b/stores/cloudflare/cloudflare.go index 9375b7f..3c22f93 100644 --- a/stores/cloudflare/cloudflare.go +++ b/stores/cloudflare/cloudflare.go @@ -4,28 +4,38 @@ import ( "context" "errors" "fmt" - "strconv" "strings" "github.com/cloudflare/cloudflare-go/v5" "github.com/cloudflare/cloudflare-go/v5/custom_certificates" "github.com/cloudflare/cloudflare-go/v5/option" + "github.com/cloudflare/cloudflare-go/v5/origin_tls_client_auth" "github.com/robertlestak/cert-manager-sync/pkg/state" "github.com/robertlestak/cert-manager-sync/pkg/tlssecret" log "github.com/sirupsen/logrus" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +const ( + // ModeCustomCertificate uploads an edge certificate served to visitors. + // Cloudflare must be able to bundle it against its own trust store, so the + // certificate has to chain to a publicly trusted CA. + ModeCustomCertificate = "custom-certificate" + // ModeOriginPull uploads an Authenticated Origin Pulls client certificate + // that Cloudflare presents to the origin. It takes the leaf and the private + // key only, and the issuing CA may be private. + ModeOriginPull = "origin-pull" +) + type CloudflareStore struct { SecretName string SecretNamespace string ApiToken string ZoneId string CertId string - // LeafOnly, when true, uploads only the leaf certificate to Cloudflare - // instead of the full chain (leaf + CA). Some CA bundles are rejected by - // Cloudflare's custom_certificates trust store validation. - LeafOnly bool + // Mode selects which Cloudflare certificate store to sync to. An empty + // value means ModeCustomCertificate. + Mode string } func (s *CloudflareStore) GetApiToken(ctx context.Context) error { @@ -58,12 +68,13 @@ func (s *CloudflareStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error if c.Config["cert-id"] != "" { s.CertId = c.Config["cert-id"] } - if c.Config["leaf-only"] != "" { - leafOnly, err := strconv.ParseBool(c.Config["leaf-only"]) - if err != nil { - return fmt.Errorf("invalid leaf-only value %q: %w", c.Config["leaf-only"], err) + if c.Config["mode"] != "" { + switch c.Config["mode"] { + case ModeCustomCertificate, ModeOriginPull: + s.Mode = c.Config["mode"] + default: + return fmt.Errorf("invalid mode %q: must be %q or %q", c.Config["mode"], ModeCustomCertificate, ModeOriginPull) } - s.LeafOnly = leafOnly } // if secret name is in the format of "namespace/secretname" then parse it if strings.Contains(s.SecretName, "/") { @@ -79,16 +90,6 @@ func (s *CloudflareStore) setDefaultSecretNamespace(namespace string) { } } -// certificatePayload returns the certificate bytes to upload to Cloudflare. -// When LeafOnly is set, the CA certificate is omitted so only the leaf -// certificate is sent, avoiding Cloudflare trust-store bundling rejections. -func (s *CloudflareStore) certificatePayload(c *tlssecret.Certificate) []byte { - if s.LeafOnly { - return c.Certificate - } - return c.FullChain() -} - func (s *CloudflareStore) Sync(c *tlssecret.Certificate) (map[string]string, error) { s.setDefaultSecretNamespace(c.Namespace) l := log.WithFields(log.Fields{ @@ -109,42 +110,85 @@ func (s *CloudflareStore) Sync(c *tlssecret.Certificate) (map[string]string, err client := cloudflare.NewClient(option.WithAPIToken(s.ApiToken)) origCertId := s.CertId + var err error + if s.Mode == ModeOriginPull { + err = s.syncOriginPull(ctx, client, c, l) + } else { + err = s.syncCustomCertificate(ctx, client, c, l) + } + if err != nil { + return nil, err + } + l = l.WithField("id", s.CertId) + var newKeys map[string]string + if origCertId != s.CertId { + newKeys = map[string]string{ + "cert-id": s.CertId, + } + } + l.Info("certificate synced") + return newKeys, nil +} + +// syncCustomCertificate uploads the full chain as an edge certificate, +// updating in place when a cert-id was recorded by a previous sync. +func (s *CloudflareStore) syncCustomCertificate(ctx context.Context, client *cloudflare.Client, c *tlssecret.Certificate, l *log.Entry) error { var cert *custom_certificates.CustomCertificate var err error - certPayload := s.certificatePayload(c) if s.CertId != "" { // Update existing certificate cert, err = client.CustomCertificates.Edit(ctx, s.CertId, custom_certificates.CustomCertificateEditParams{ ZoneID: cloudflare.F(s.ZoneId), - Certificate: cloudflare.F(string(certPayload)), + Certificate: cloudflare.F(string(c.FullChain())), PrivateKey: cloudflare.F(string(c.Key)), }) if err != nil { l.WithError(err).Errorf("cloudflare.CustomCertificates.Edit error") - return nil, fmt.Errorf("failed to update certificate in Cloudflare (zone: %s, cert: %s): %w", s.ZoneId, s.CertId, err) + return fmt.Errorf("failed to update certificate in Cloudflare (zone: %s, cert: %s): %w", s.ZoneId, s.CertId, err) } } else { // Create new certificate cert, err = client.CustomCertificates.New(ctx, custom_certificates.CustomCertificateNewParams{ ZoneID: cloudflare.F(s.ZoneId), - Certificate: cloudflare.F(string(certPayload)), + Certificate: cloudflare.F(string(c.FullChain())), PrivateKey: cloudflare.F(string(c.Key)), }) if err != nil { l.WithError(err).Errorf("cloudflare.CustomCertificates.New error") - return nil, fmt.Errorf("failed to create certificate in Cloudflare (zone: %s): %w", s.ZoneId, err) + return fmt.Errorf("failed to create certificate in Cloudflare (zone: %s): %w", s.ZoneId, err) } } s.CertId = cert.ID - l = l.WithField("id", cert.ID) - var newKeys map[string]string - if origCertId != s.CertId { - newKeys = map[string]string{ - "cert-id": s.CertId, - } + return nil +} + +// syncOriginPull uploads the leaf certificate and its key for Authenticated +// Origin Pulls. The CA certificate is deliberately left out: Cloudflare rejects +// anything but a leaf here. The API has no update method, so a renewal uploads +// a new certificate and then drops the one it replaced. +func (s *CloudflareStore) syncOriginPull(ctx context.Context, client *cloudflare.Client, c *tlssecret.Certificate, l *log.Entry) error { + cert, err := client.OriginTLSClientAuth.New(ctx, origin_tls_client_auth.OriginTLSClientAuthNewParams{ + ZoneID: cloudflare.F(s.ZoneId), + Certificate: cloudflare.F(string(c.Certificate)), + PrivateKey: cloudflare.F(string(c.Key)), + }) + if err != nil { + l.WithError(err).Errorf("cloudflare.OriginTLSClientAuth.New error") + return fmt.Errorf("failed to upload origin pull certificate to Cloudflare (zone: %s): %w", s.ZoneId, err) } - l.Info("certificate synced") - return newKeys, nil + replacedCertId := s.CertId + s.CertId = cert.ID + if replacedCertId == "" || replacedCertId == s.CertId { + return nil + } + if _, err := client.OriginTLSClientAuth.Delete(ctx, replacedCertId, origin_tls_client_auth.OriginTLSClientAuthDeleteParams{ + ZoneID: cloudflare.F(s.ZoneId), + }); err != nil && !isCloudflareNotFound(err) { + // The renewed certificate is already live, so a leftover certificate is + // not worth failing (and retrying) the whole sync over. + l.WithError(err).WithField("replacedId", replacedCertId).Warn("failed to remove replaced origin pull certificate") + } + return nil } // isCloudflareNotFound returns true when the error reports a 404 from the @@ -160,13 +204,14 @@ func isCloudflareNotFound(err error) bool { return false } -// Delete removes the custom certificate from Cloudflare. 404 responses are -// treated as success so the operation is idempotent. +// Delete removes the certificate from Cloudflare. 404 responses are treated as +// success so the operation is idempotent. func (s *CloudflareStore) Delete(ctx context.Context) error { l := log.WithFields(log.Fields{ "action": "cloudflare.Delete", "id": s.CertId, "zone-id": s.ZoneId, + "mode": s.Mode, }) if s.CertId == "" { // Sync never populated cert-id, so there is no remote certificate @@ -185,9 +230,17 @@ func (s *CloudflareStore) Delete(ctx context.Context) error { return fmt.Errorf("cloudflare credentials lookup failed: %w", err) } client := cloudflare.NewClient(option.WithAPIToken(s.ApiToken)) - if _, err := client.CustomCertificates.Delete(ctx, s.CertId, custom_certificates.CustomCertificateDeleteParams{ - ZoneID: cloudflare.F(s.ZoneId), - }); err != nil { + var err error + if s.Mode == ModeOriginPull { + _, err = client.OriginTLSClientAuth.Delete(ctx, s.CertId, origin_tls_client_auth.OriginTLSClientAuthDeleteParams{ + ZoneID: cloudflare.F(s.ZoneId), + }) + } else { + _, err = client.CustomCertificates.Delete(ctx, s.CertId, custom_certificates.CustomCertificateDeleteParams{ + ZoneID: cloudflare.F(s.ZoneId), + }) + } + if err != nil { if isCloudflareNotFound(err) { l.Debug("cloudflare certificate already absent; treating delete as success") return nil diff --git a/stores/cloudflare/cloudflare_test.go b/stores/cloudflare/cloudflare_test.go index ad37980..9313c7e 100644 --- a/stores/cloudflare/cloudflare_test.go +++ b/stores/cloudflare/cloudflare_test.go @@ -64,24 +64,24 @@ func TestCloudflareFromConfigParsesNamespacedSecretName(t *testing.T) { assert.Equal(t, "cert", s.CertId) } -func TestCloudflareFromConfigParsesLeafOnly(t *testing.T) { +func TestCloudflareFromConfigParsesMode(t *testing.T) { cases := []struct { name string value string - want bool + want string wantErr bool }{ - {name: "true", value: "true", want: true}, - {name: "false", value: "false", want: false}, - {name: "unset defaults to false", value: "", want: false}, - {name: "invalid value errors", value: "yes", wantErr: true}, + {name: "origin pull", value: ModeOriginPull, want: ModeOriginPull}, + {name: "custom certificate", value: ModeCustomCertificate, want: ModeCustomCertificate}, + {name: "unset defaults to custom certificate", value: "", want: ""}, + {name: "unknown mode errors", value: "edge", wantErr: true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s := &CloudflareStore{} cfg := map[string]string{"zone-id": "zone"} if tc.value != "" { - cfg["leaf-only"] = tc.value + cfg["mode"] = tc.value } err := s.FromConfig(tlssecret.GenericSecretSyncConfig{Config: cfg}) if tc.wantErr { @@ -89,30 +89,11 @@ func TestCloudflareFromConfigParsesLeafOnly(t *testing.T) { return } assert.NoError(t, err) - assert.Equal(t, tc.want, s.LeafOnly) + assert.Equal(t, tc.want, s.Mode) }) } } -func TestCloudflareCertificatePayload(t *testing.T) { - cert := &tlssecret.Certificate{ - Certificate: []byte("leaf"), - Ca: []byte("ca"), - } - - t.Run("full chain by default", func(t *testing.T) { - s := &CloudflareStore{} - assert.Equal(t, cert.FullChain(), s.certificatePayload(cert)) - assert.Contains(t, string(s.certificatePayload(cert)), "ca") - }) - - t.Run("leaf only when enabled", func(t *testing.T) { - s := &CloudflareStore{LeafOnly: true} - assert.Equal(t, cert.Certificate, s.certificatePayload(cert)) - assert.NotContains(t, string(s.certificatePayload(cert)), "ca") - }) -} - func TestCloudflareSetDefaultSecretNamespace(t *testing.T) { t.Run("defaults when empty", func(t *testing.T) { s := &CloudflareStore{} From e00f36162f3eb3a76675cecbbc48aaea579e623c Mon Sep 17 00:00:00 2001 From: Romain Forlot Date: Sat, 5 Sep 2026 12:52:02 +0200 Subject: [PATCH 4/4] feat(cloudflare): support per-hostname Authenticated Origin Pulls The origin-pull mode uploads a zone-level client certificate, which applies to every hostname in the zone. Zones that need a different client certificate per hostname have no way to express that. Add an origin-pull-hostname mode that uploads to the per-hostname endpoint and associates the certificate with the hostnames listed in a new cloudflare-hostnames annotation. Associating a hostname is what puts the certificate in use, so it runs before the replaced certificate is removed; if the association fails, the freshly uploaded certificate is deleted so retries do not pile up unused certificates. Both mode and hostnames are validated together: the mode requires a non-empty list, and the list is rejected in any other mode rather than being silently ignored. --- README.md | 37 ++++++--- stores/cloudflare/cloudflare.go | 110 ++++++++++++++++++++++++--- stores/cloudflare/cloudflare_test.go | 56 ++++++++++++++ 3 files changed, 183 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 24dc60d..ce83098 100644 --- a/README.md +++ b/README.md @@ -138,12 +138,13 @@ Annotations: cert-manager-sync.lestak.sh/cloudflare-secret-name: "example-cloudflare-secret" # secret in same namespace which contains the cloudflare api token. If provided in format "namespace/secret-name", will look in that namespace for the secret cert-manager-sync.lestak.sh/cloudflare-zone-id: "example-zone-id" # cloudflare zone id cert-manager-sync.lestak.sh/cloudflare-cert-id: "" # will be auto-filled by operator for in-place renewals - cert-manager-sync.lestak.sh/cloudflare-mode: "custom-certificate" # "custom-certificate" (default) uploads an edge certificate served to visitors, which must chain to a publicly trusted CA. "origin-pull" uploads an Authenticated Origin Pulls client certificate that Cloudflare presents to your origin: only the leaf and the key are sent, and the issuing CA may be private. + cert-manager-sync.lestak.sh/cloudflare-mode: "custom-certificate" # "custom-certificate" (default) uploads an edge certificate served to visitors, which must chain to a publicly trusted CA. "origin-pull" uploads a zone-level Authenticated Origin Pulls client certificate that Cloudflare presents to your origin: only the leaf and the key are sent, and the issuing CA may be private. "origin-pull-hostname" does the same, scoped to the hostnames below. + cert-manager-sync.lestak.sh/cloudflare-hostnames: "" # comma-separated fully qualified domain names to associate the certificate with. Required by, and only valid with, mode "origin-pull-hostname" ``` #### Choosing a mode -The two modes target different Cloudflare certificate stores, and they are not +The modes target different Cloudflare certificate stores, and they are not interchangeable: - `custom-certificate` (default) uploads an **edge certificate**, the one @@ -151,14 +152,27 @@ interchangeable: store, so it must chain to a publicly trusted CA. Syncing a certificate issued by an internal CA here fails with `The certificate chain you uploaded cannot be bundled using Cloudflare's trust store`. -- `origin-pull` uploads a **client certificate for Authenticated Origin Pulls**, - which Cloudflare presents to your origin so the origin can authenticate - incoming requests. Only the leaf certificate and the private key are sent — - Cloudflare rejects a CA certificate on this endpoint — and the issuing CA may - be private, which is the usual setup for origin mTLS. - -Authenticated Origin Pulls has no update endpoint, so a renewal uploads the new -certificate and then removes the one it replaced. +- `origin-pull` uploads a **zone-level client certificate for Authenticated + Origin Pulls**, which Cloudflare presents to your origin so the origin can + authenticate incoming requests. Only the leaf certificate and the private key + are sent — Cloudflare rejects a CA certificate on this endpoint — and the + issuing CA may be private, which is the usual setup for origin mTLS. +- `origin-pull-hostname` uploads the same kind of client certificate, but + associates it with the hostnames listed in `cloudflare-hostnames` instead of + applying to the entire zone: + + ```yaml + cert-manager-sync.lestak.sh/cloudflare-mode: "origin-pull-hostname" + cert-manager-sync.lestak.sh/cloudflare-hostnames: "api.example.com,geo.example.com" + ``` + + Reach for this when different hostnames in one zone need different client + certificates. Per-hostname certificates take precedence over the zone-level + one for the hostnames they cover, and the two settings are otherwise + independent — enabling one does not change the other. + +Authenticated Origin Pulls has no update endpoint in either mode, so a renewal +uploads the new certificate and then removes the one it replaced. ### DigitalOcean @@ -585,7 +599,8 @@ metadata: cert-manager-sync.lestak.sh/cloudflare-secret-name: "example-cloudflare-secret" # secret in same namespace which contains the cloudflare api token. If provided in format "namespace/secret-name", will look in that namespace for the secret cert-manager-sync.lestak.sh/cloudflare-zone-id: "example-zone-id" # cloudflare zone id cert-manager-sync.lestak.sh/cloudflare-cert-id: "" # will be auto-filled by operator for in-place renewals - cert-manager-sync.lestak.sh/cloudflare-mode: "custom-certificate" # "custom-certificate" (default) uploads an edge certificate served to visitors, which must chain to a publicly trusted CA. "origin-pull" uploads an Authenticated Origin Pulls client certificate that Cloudflare presents to your origin: only the leaf and the key are sent, and the issuing CA may be private. + cert-manager-sync.lestak.sh/cloudflare-mode: "custom-certificate" # "custom-certificate" (default) uploads an edge certificate served to visitors, which must chain to a publicly trusted CA. "origin-pull" uploads a zone-level Authenticated Origin Pulls client certificate that Cloudflare presents to your origin: only the leaf and the key are sent, and the issuing CA may be private. "origin-pull-hostname" does the same, scoped to the hostnames below. + cert-manager-sync.lestak.sh/cloudflare-hostnames: "" # comma-separated fully qualified domain names to associate the certificate with. Required by, and only valid with, mode "origin-pull-hostname" cert-manager-sync.lestak.sh/digitalocean-enabled: "true" # sync certificate to DigitalOcean cert-manager-sync.lestak.sh/digitalocean-secret-name: "example-digitalocean-secret" # secret in same namespace which contains the digitalocean api key. If provided in format "namespace/secret-name", will look in that namespace for the secret cert-manager-sync.lestak.sh/digitalocean-cert-name: "my-cert" # unique name to give your cert in DigitalOcean diff --git a/stores/cloudflare/cloudflare.go b/stores/cloudflare/cloudflare.go index 3c22f93..8d5b40e 100644 --- a/stores/cloudflare/cloudflare.go +++ b/stores/cloudflare/cloudflare.go @@ -21,10 +21,14 @@ const ( // Cloudflare must be able to bundle it against its own trust store, so the // certificate has to chain to a publicly trusted CA. ModeCustomCertificate = "custom-certificate" - // ModeOriginPull uploads an Authenticated Origin Pulls client certificate - // that Cloudflare presents to the origin. It takes the leaf and the private - // key only, and the issuing CA may be private. + // ModeOriginPull uploads a zone-level Authenticated Origin Pulls client + // certificate that Cloudflare presents to the origin. It takes the leaf and + // the private key only, and the issuing CA may be private. ModeOriginPull = "origin-pull" + // ModeOriginPullHostname is ModeOriginPull scoped to specific hostnames + // instead of the whole zone. It requires Hostnames to be set, and takes + // precedence over the zone-level certificate for those hostnames. + ModeOriginPullHostname = "origin-pull-hostname" ) type CloudflareStore struct { @@ -36,6 +40,9 @@ type CloudflareStore struct { // Mode selects which Cloudflare certificate store to sync to. An empty // value means ModeCustomCertificate. Mode string + // Hostnames are the fully qualified domain names to associate the + // certificate with, for ModeOriginPullHostname only. + Hostnames []string } func (s *CloudflareStore) GetApiToken(ctx context.Context) error { @@ -70,12 +77,21 @@ func (s *CloudflareStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error } if c.Config["mode"] != "" { switch c.Config["mode"] { - case ModeCustomCertificate, ModeOriginPull: + case ModeCustomCertificate, ModeOriginPull, ModeOriginPullHostname: s.Mode = c.Config["mode"] default: - return fmt.Errorf("invalid mode %q: must be %q or %q", c.Config["mode"], ModeCustomCertificate, ModeOriginPull) + return fmt.Errorf("invalid mode %q: must be one of %q, %q, %q", c.Config["mode"], ModeCustomCertificate, ModeOriginPull, ModeOriginPullHostname) } } + if c.Config["hostnames"] != "" { + s.Hostnames = parseHostnames(c.Config["hostnames"]) + } + if s.Mode == ModeOriginPullHostname && len(s.Hostnames) == 0 { + return fmt.Errorf("mode %q requires a non-empty hostnames list", ModeOriginPullHostname) + } + if len(s.Hostnames) > 0 && s.Mode != ModeOriginPullHostname { + return fmt.Errorf("hostnames is only valid with mode %q", ModeOriginPullHostname) + } // if secret name is in the format of "namespace/secretname" then parse it if strings.Contains(s.SecretName, "/") { s.SecretNamespace = strings.Split(s.SecretName, "/")[0] @@ -84,6 +100,18 @@ func (s *CloudflareStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error return nil } +// parseHostnames splits a comma separated hostname list, dropping blanks so a +// trailing comma or a padded value does not turn into an empty association. +func parseHostnames(v string) []string { + var hostnames []string + for _, h := range strings.Split(v, ",") { + if h = strings.TrimSpace(h); h != "" { + hostnames = append(hostnames, h) + } + } + return hostnames +} + func (s *CloudflareStore) setDefaultSecretNamespace(namespace string) { if s.SecretNamespace == "" { s.SecretNamespace = namespace @@ -111,9 +139,12 @@ func (s *CloudflareStore) Sync(c *tlssecret.Certificate) (map[string]string, err origCertId := s.CertId var err error - if s.Mode == ModeOriginPull { + switch s.Mode { + case ModeOriginPull: err = s.syncOriginPull(ctx, client, c, l) - } else { + case ModeOriginPullHostname: + err = s.syncOriginPullHostname(ctx, client, c, l) + default: err = s.syncCustomCertificate(ctx, client, c, l) } if err != nil { @@ -191,6 +222,62 @@ func (s *CloudflareStore) syncOriginPull(ctx context.Context, client *cloudflare return nil } +// syncOriginPullHostname uploads the leaf and key as a per-hostname +// Authenticated Origin Pulls certificate, then associates it with the +// configured hostnames. Like the zone-level endpoint it has no update method, +// so a renewal uploads a new certificate and drops the one it replaced. +func (s *CloudflareStore) syncOriginPullHostname(ctx context.Context, client *cloudflare.Client, c *tlssecret.Certificate, l *log.Entry) error { + cert, err := client.OriginTLSClientAuth.Hostnames.Certificates.New(ctx, origin_tls_client_auth.HostnameCertificateNewParams{ + ZoneID: cloudflare.F(s.ZoneId), + Certificate: cloudflare.F(string(c.Certificate)), + PrivateKey: cloudflare.F(string(c.Key)), + }) + if err != nil { + l.WithError(err).Errorf("cloudflare.OriginTLSClientAuth.Hostnames.Certificates.New error") + return fmt.Errorf("failed to upload per-hostname origin pull certificate to Cloudflare (zone: %s): %w", s.ZoneId, err) + } + replacedCertId := s.CertId + s.CertId = cert.ID + + // Associating the hostnames is what puts the new certificate in use, so it + // has to succeed before the one it replaces is removed. + configs := make([]origin_tls_client_auth.HostnameUpdateParamsConfig, 0, len(s.Hostnames)) + for _, hostname := range s.Hostnames { + configs = append(configs, origin_tls_client_auth.HostnameUpdateParamsConfig{ + CERTID: cloudflare.F(s.CertId), + Enabled: cloudflare.F(true), + Hostname: cloudflare.F(hostname), + }) + } + if _, err := client.OriginTLSClientAuth.Hostnames.Update(ctx, origin_tls_client_auth.HostnameUpdateParams{ + ZoneID: cloudflare.F(s.ZoneId), + Config: cloudflare.F(configs), + }); err != nil { + l.WithError(err).Errorf("cloudflare.OriginTLSClientAuth.Hostnames.Update error") + // The upload landed but is associated with nothing. Drop it, otherwise + // every retry of this sync leaves another unused certificate behind. + if _, derr := client.OriginTLSClientAuth.Hostnames.Certificates.Delete(ctx, s.CertId, origin_tls_client_auth.HostnameCertificateDeleteParams{ + ZoneID: cloudflare.F(s.ZoneId), + }); derr != nil && !isCloudflareNotFound(derr) { + l.WithError(derr).WithField("certId", s.CertId).Warn("failed to remove unassociated origin pull certificate") + } + s.CertId = replacedCertId + return fmt.Errorf("failed to associate hostnames [%s] with certificate %s (zone: %s): %w", strings.Join(s.Hostnames, ", "), cert.ID, s.ZoneId, err) + } + + if replacedCertId == "" || replacedCertId == s.CertId { + return nil + } + if _, err := client.OriginTLSClientAuth.Hostnames.Certificates.Delete(ctx, replacedCertId, origin_tls_client_auth.HostnameCertificateDeleteParams{ + ZoneID: cloudflare.F(s.ZoneId), + }); err != nil && !isCloudflareNotFound(err) { + // The renewed certificate is already associated, so a leftover + // certificate is not worth failing (and retrying) the whole sync over. + l.WithError(err).WithField("replacedId", replacedCertId).Warn("failed to remove replaced per-hostname origin pull certificate") + } + return nil +} + // isCloudflareNotFound returns true when the error reports a 404 from the // Cloudflare API. func isCloudflareNotFound(err error) bool { @@ -231,11 +318,16 @@ func (s *CloudflareStore) Delete(ctx context.Context) error { } client := cloudflare.NewClient(option.WithAPIToken(s.ApiToken)) var err error - if s.Mode == ModeOriginPull { + switch s.Mode { + case ModeOriginPull: _, err = client.OriginTLSClientAuth.Delete(ctx, s.CertId, origin_tls_client_auth.OriginTLSClientAuthDeleteParams{ ZoneID: cloudflare.F(s.ZoneId), }) - } else { + case ModeOriginPullHostname: + _, err = client.OriginTLSClientAuth.Hostnames.Certificates.Delete(ctx, s.CertId, origin_tls_client_auth.HostnameCertificateDeleteParams{ + ZoneID: cloudflare.F(s.ZoneId), + }) + default: _, err = client.CustomCertificates.Delete(ctx, s.CertId, custom_certificates.CustomCertificateDeleteParams{ ZoneID: cloudflare.F(s.ZoneId), }) diff --git a/stores/cloudflare/cloudflare_test.go b/stores/cloudflare/cloudflare_test.go index 9313c7e..032502c 100644 --- a/stores/cloudflare/cloudflare_test.go +++ b/stores/cloudflare/cloudflare_test.go @@ -94,6 +94,62 @@ func TestCloudflareFromConfigParsesMode(t *testing.T) { } } +func TestCloudflareFromConfigParsesHostnames(t *testing.T) { + cases := []struct { + name string + mode string + hostnames string + want []string + wantErr bool + }{ + { + name: "single hostname", + mode: ModeOriginPullHostname, + hostnames: "a.example.com", + want: []string{"a.example.com"}, + }, + { + name: "list is split and trimmed", + mode: ModeOriginPullHostname, + hostnames: "a.example.com, b.example.com ,,c.example.com,", + want: []string{"a.example.com", "b.example.com", "c.example.com"}, + }, + { + name: "per-hostname mode without hostnames errors", + mode: ModeOriginPullHostname, + wantErr: true, + }, + { + name: "per-hostname mode with only blanks errors", + mode: ModeOriginPullHostname, + hostnames: " , ", + wantErr: true, + }, + { + name: "hostnames without per-hostname mode errors", + mode: ModeOriginPull, + hostnames: "a.example.com", + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := &CloudflareStore{} + cfg := map[string]string{"zone-id": "zone", "mode": tc.mode} + if tc.hostnames != "" { + cfg["hostnames"] = tc.hostnames + } + err := s.FromConfig(tlssecret.GenericSecretSyncConfig{Config: cfg}) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, s.Hostnames) + }) + } +} + func TestCloudflareSetDefaultSecretNamespace(t *testing.T) { t.Run("defaults when empty", func(t *testing.T) { s := &CloudflareStore{}