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 <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-25 09:54:40 +01:00
parent 63965261db
commit 9b66d05c3c
4 changed files with 80 additions and 19 deletions

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>. // Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above // 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), ProvisionInterval: b.getEnvIntOrDefault("CUSTOM_DOMAINS_PROVISION_INTERVAL", 30),
CnameTarget: b.getEnvOrDefault("CUSTOM_DOMAINS_CNAME_TARGET", "custom.getprobo.com"), CnameTarget: b.getEnvOrDefault("CUSTOM_DOMAINS_CNAME_TARGET", "custom.getprobo.com"),
ResolverAddr: b.getEnvOrDefault("CUSTOM_DOMAINS_RESOLVER_ADDR", "8.8.8.8:53"), 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{ ACME: probod.ACMEConfig{
Directory: b.getEnvOrDefault("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory"), Directory: b.getEnvOrDefault("ACME_DIRECTORY", "https://acme-v02.api.letsencrypt.org/directory"),
Email: b.getEnvOrDefault("ACME_EMAIL", "admin@getprobo.com"), Email: b.getEnvOrDefault("ACME_EMAIL", "admin@getprobo.com"),

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>. // Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above // purpose with or without fee is hereby granted, provided that the above
@@ -36,6 +36,7 @@ type (
acmeService *ACMEService acmeService *ACMEService
encryptionKey cipher.EncryptionKey encryptionKey cipher.EncryptionKey
cnameTarget string cnameTarget string
caaIssuerDomain string
interval time.Duration interval time.Duration
resolverAddr string resolverAddr string
logger *log.Logger logger *log.Logger
@@ -51,6 +52,7 @@ func NewProvisioner(
acmeService *ACMEService, acmeService *ACMEService,
encryptionKey cipher.EncryptionKey, encryptionKey cipher.EncryptionKey,
cnameTarget string, cnameTarget string,
caaIssuerDomain string,
interval time.Duration, interval time.Duration,
resolverAddr string, resolverAddr string,
logger *log.Logger, logger *log.Logger,
@@ -60,6 +62,7 @@ func NewProvisioner(
acmeService: acmeService, acmeService: acmeService,
encryptionKey: encryptionKey, encryptionKey: encryptionKey,
cnameTarget: cnameTarget, cnameTarget: cnameTarget,
caaIssuerDomain: caaIssuerDomain,
interval: interval, interval: interval,
resolverAddr: resolverAddr, resolverAddr: resolverAddr,
logger: logger.Named("certmanager.provisioner"), logger: logger.Named("certmanager.provisioner"),
@@ -135,6 +138,51 @@ func (p *Provisioner) checkDNSConfiguration(domain string) error {
return nil 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 { func (p *Provisioner) checkPendingDomains(ctx context.Context) error {
err := p.pg.WithTx( err := p.pg.WithTx(
ctx, ctx,
@@ -293,6 +341,17 @@ func (p *Provisioner) provisionDomainCertificate(
return err 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)) 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) challenge, err := p.acmeService.GetHTTPChallenge(ctx, domain.Domain)

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>. // Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above // purpose with or without fee is hereby granted, provided that the above
@@ -19,6 +19,7 @@ type CustomDomainsConfig struct {
ProvisionInterval int `json:"provision-interval"` ProvisionInterval int `json:"provision-interval"`
ResolverAddr string `json:"resolver-addr"` ResolverAddr string `json:"resolver-addr"`
CnameTarget string `json:"cname-target"` CnameTarget string `json:"cname-target"`
CAAIssuerDomain string `json:"caa-issuer-domain"`
ACME ACMEConfig `json:"acme"` ACME ACMEConfig `json:"acme"`
} }

View File

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>. // Copyright (c) 2025-2026 Probo Inc <hello@getprobo.com>.
// //
// Permission to use, copy, modify, and/or distribute this software for any // Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above // purpose with or without fee is hereby granted, provided that the above
@@ -830,7 +830,7 @@ func (impl *Implm) runTrustCenterServer(
if certProvisioningInterval == 0 { if certProvisioningInterval == 0 {
certProvisioningInterval = 30 * time.Second 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) g, ctx := errgroup.WithContext(ctx)