diff --git a/README.md b/README.md index adad496..ce83098 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,41 @@ 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 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 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 **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 @@ -565,6 +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 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 44bee8b..8d5b40e 100644 --- a/stores/cloudflare/cloudflare.go +++ b/stores/cloudflare/cloudflare.go @@ -9,18 +9,40 @@ import ( "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 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 { SecretName string SecretNamespace string ApiToken string ZoneId string CertId string + // 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 { @@ -53,6 +75,23 @@ func (s *CloudflareStore) FromConfig(c tlssecret.GenericSecretSyncConfig) error if c.Config["cert-id"] != "" { s.CertId = c.Config["cert-id"] } + if c.Config["mode"] != "" { + switch c.Config["mode"] { + case ModeCustomCertificate, ModeOriginPull, ModeOriginPullHostname: + s.Mode = c.Config["mode"] + default: + 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] @@ -61,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 @@ -87,6 +138,32 @@ func (s *CloudflareStore) Sync(c *tlssecret.Certificate) (map[string]string, err client := cloudflare.NewClient(option.WithAPIToken(s.ApiToken)) origCertId := s.CertId + var err error + switch s.Mode { + case ModeOriginPull: + err = s.syncOriginPull(ctx, client, c, l) + case ModeOriginPullHostname: + err = s.syncOriginPullHostname(ctx, client, c, l) + default: + 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 if s.CertId != "" { @@ -98,7 +175,7 @@ func (s *CloudflareStore) Sync(c *tlssecret.Certificate) (map[string]string, err }) 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 @@ -109,19 +186,96 @@ func (s *CloudflareStore) Sync(c *tlssecret.Certificate) (map[string]string, err }) 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) + } + 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 +} + +// 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) } - l.Info("certificate synced") - return newKeys, nil + + 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 @@ -137,13 +291,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 @@ -162,9 +317,22 @@ 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 + switch s.Mode { + case ModeOriginPull: + _, err = client.OriginTLSClientAuth.Delete(ctx, s.CertId, origin_tls_client_auth.OriginTLSClientAuthDeleteParams{ + ZoneID: cloudflare.F(s.ZoneId), + }) + 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), + }) + } + 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 59c3235..032502c 100644 --- a/stores/cloudflare/cloudflare_test.go +++ b/stores/cloudflare/cloudflare_test.go @@ -64,6 +64,92 @@ func TestCloudflareFromConfigParsesNamespacedSecretName(t *testing.T) { assert.Equal(t, "cert", s.CertId) } +func TestCloudflareFromConfigParsesMode(t *testing.T) { + cases := []struct { + name string + value string + want string + wantErr bool + }{ + {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["mode"] = 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.Mode) + }) + } +} + +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{}