From 9b66d05c3cbfec4d9f99757ba0a547fdced17900 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Wed, 25 Mar 2026 09:54:40 +0100 Subject: [PATCH] Check CAA records before ACME certificate issuance Before requesting a certificate from the ACME provider, verify that CAA DNS records for the domain permit issuance by the configured CA. This avoids wasting ACME attempts on domains whose CAA policy would reject the request. Signed-off-by: Bryan Frimin --- pkg/bootstrap/builder.go | 3 +- pkg/certmanager/provisioner.go | 89 ++++++++++++++++++++++++----- pkg/probod/custom_domains_config.go | 3 +- pkg/probod/probod.go | 4 +- 4 files changed, 80 insertions(+), 19 deletions(-) diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index 805520d95..3b4afc22f 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -168,6 +168,7 @@ func (b *Builder) Build() (*probod.FullConfig, error) { ProvisionInterval: b.getEnvIntOrDefault("CUSTOM_DOMAINS_PROVISION_INTERVAL", 30), CnameTarget: b.getEnvOrDefault("CUSTOM_DOMAINS_CNAME_TARGET", "custom.getprobo.com"), ResolverAddr: b.getEnvOrDefault("CUSTOM_DOMAINS_RESOLVER_ADDR", "8.8.8.8:53"), + CAAIssuerDomain: b.getEnvOrDefault("CUSTOM_DOMAINS_CAA_ISSUER_DOMAIN", "letsencrypt.org"), ACME: probod.ACMEConfig{ Directory: b.getEnvOrDefault("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory"), Email: b.getEnvOrDefault("ACME_EMAIL", "admin@getprobo.com"), diff --git a/pkg/certmanager/provisioner.go b/pkg/certmanager/provisioner.go index e1d784636..991e7014a 100644 --- a/pkg/certmanager/provisioner.go +++ b/pkg/certmanager/provisioner.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -32,13 +32,14 @@ import ( type ( Provisioner struct { - pg *pg.Client - acmeService *ACMEService - encryptionKey cipher.EncryptionKey - cnameTarget string - interval time.Duration - resolverAddr string - logger *log.Logger + pg *pg.Client + acmeService *ACMEService + encryptionKey cipher.EncryptionKey + cnameTarget string + caaIssuerDomain string + interval time.Duration + resolverAddr string + logger *log.Logger } ) @@ -51,18 +52,20 @@ func NewProvisioner( acmeService *ACMEService, encryptionKey cipher.EncryptionKey, cnameTarget string, + caaIssuerDomain string, interval time.Duration, resolverAddr string, logger *log.Logger, ) *Provisioner { return &Provisioner{ - pg: pg, - acmeService: acmeService, - encryptionKey: encryptionKey, - cnameTarget: cnameTarget, - interval: interval, - resolverAddr: resolverAddr, - logger: logger.Named("certmanager.provisioner"), + pg: pg, + acmeService: acmeService, + encryptionKey: encryptionKey, + cnameTarget: cnameTarget, + caaIssuerDomain: caaIssuerDomain, + interval: interval, + resolverAddr: resolverAddr, + logger: logger.Named("certmanager.provisioner"), } } @@ -135,6 +138,51 @@ func (p *Provisioner) checkDNSConfiguration(domain string) error { return nil } +func (p *Provisioner) checkCAARecords(domain string) error { + fqdn := domain + if !strings.HasSuffix(fqdn, ".") { + fqdn = fqdn + "." + } + + msg := &dns.Msg{MsgHeader: dns.MsgHeader{ID: dns.ID(), RecursionDesired: true}} + msg.Question = []dns.RR{&dns.CAA{Hdr: dns.Header{Name: fqdn, Class: dns.ClassINET}}} + + client := dns.NewClient() + + resp, _, err := client.Exchange( + context.Background(), + msg, + "udp", + p.resolverAddr, + ) + if err != nil { + return fmt.Errorf("cannot exchange dns message for caa records: %w", err) + } + + var caaRecords []*dns.CAA + for _, rr := range resp.Answer { + if caa, ok := rr.(*dns.CAA); ok { + caaRecords = append(caaRecords, caa) + } + } + + if len(caaRecords) == 0 { + return nil + } + + for _, caa := range caaRecords { + if caa.Tag == "issue" && strings.EqualFold(caa.Value, p.caaIssuerDomain) { + return nil + } + } + + return fmt.Errorf( + "caa records for domain %q do not permit issuance by %q", + domain, + p.caaIssuerDomain, + ) +} + func (p *Provisioner) checkPendingDomains(ctx context.Context) error { err := p.pg.WithTx( ctx, @@ -293,6 +341,17 @@ func (p *Provisioner) provisionDomainCertificate( return err } + if err := p.checkCAARecords(domain.Domain); err != nil { + p.logger.WarnCtx( + ctx, + "caa record check failed", + log.String("domain", domain.Domain), + log.Error(err), + ) + + return err + } + p.logger.InfoCtx(ctx, "DNS configuration verified, initiating HTTP challenge for domain", log.String("domain", domain.Domain)) challenge, err := p.acmeService.GetHTTPChallenge(ctx, domain.Domain) diff --git a/pkg/probod/custom_domains_config.go b/pkg/probod/custom_domains_config.go index bbaad2694..9d9eb7729 100644 --- a/pkg/probod/custom_domains_config.go +++ b/pkg/probod/custom_domains_config.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -19,6 +19,7 @@ type CustomDomainsConfig struct { ProvisionInterval int `json:"provision-interval"` ResolverAddr string `json:"resolver-addr"` CnameTarget string `json:"cname-target"` + CAAIssuerDomain string `json:"caa-issuer-domain"` ACME ACMEConfig `json:"acme"` } diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index aa79122b9..0fb31ca9d 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -1,4 +1,4 @@ -// Copyright (c) 2025 Probo Inc . +// Copyright (c) 2025-2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above @@ -830,7 +830,7 @@ func (impl *Implm) runTrustCenterServer( if certProvisioningInterval == 0 { certProvisioningInterval = 30 * time.Second } - certProvisioner := certmanager.NewProvisioner(pgClient, acmeService, encryptionKey, impl.cfg.CustomDomains.CnameTarget, certProvisioningInterval, impl.cfg.CustomDomains.ResolverAddr, l) + certProvisioner := certmanager.NewProvisioner(pgClient, acmeService, encryptionKey, impl.cfg.CustomDomains.CnameTarget, impl.cfg.CustomDomains.CAAIssuerDomain, certProvisioningInterval, impl.cfg.CustomDomains.ResolverAddr, l) g, ctx := errgroup.WithContext(ctx)