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)