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
// 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"),

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
// 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)

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
// 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"`
}

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
// 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)