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:
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user