Skip to content
Open
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
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
196 changes: 182 additions & 14 deletions stores/cloudflare/cloudflare.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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 != "" {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading