From eb11eb09703f81fb42bbdeb908d2d4c1b6628feb Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Tue, 30 Sep 2025 16:37:59 +0200 Subject: [PATCH] Add ACME workers Signed-off-by: Bryan Frimin --- pkg/cert/acme.go | 85 +++++------ pkg/cert/cache_warmer.go | 2 +- pkg/cert/certificate_provisioner.go | 186 +++++++++++++++++++++++ pkg/cert/certificate_renewer.go | 223 ++++++++++++++++++++++++++++ pkg/cert/selector.go | 185 +++++++++++++++++++++++ pkg/cert/utils.go | 24 +++ 6 files changed, 656 insertions(+), 49 deletions(-) create mode 100644 pkg/cert/certificate_provisioner.go create mode 100644 pkg/cert/certificate_renewer.go create mode 100644 pkg/cert/selector.go create mode 100644 pkg/cert/utils.go diff --git a/pkg/cert/acme.go b/pkg/cert/acme.go index dbd9c2857..3d0731f8b 100644 --- a/pkg/cert/acme.go +++ b/pkg/cert/acme.go @@ -46,25 +46,15 @@ type ( logger *log.Logger } - DNSChallenge struct { - Domain string - RecordName string - RecordValue string - Token string - URL string - OrderURL string - } - - ErrDNSChallengeRequired struct { - Domain string - Challenge *DNSChallenge + HTTPChallenge struct { + Domain string + Token string + KeyAuth string + URL string + OrderURL string } ) -func (e *ErrDNSChallengeRequired) Error() string { - return fmt.Sprintf("DNS challenge required for domain %s", e.Domain) -} - func NewACMEService(email string, keyType keys.Type, directoryURL string, logger *log.Logger) (*ACMEService, error) { accountKey, err := keys.Generate(keyType) if err != nil { @@ -111,7 +101,7 @@ func (s *ACMEService) registerAccount(ctx context.Context) error { return nil } -func (s *ACMEService) GetDNSChallenge(ctx context.Context, domain string) (*DNSChallenge, error) { +func (s *ACMEService) GetHTTPChallenge(ctx context.Context, domain string) (*HTTPChallenge, error) { order, err := s.client.AuthorizeOrder(ctx, acme.DomainIDs(domain)) if err != nil { return nil, fmt.Errorf("cannot create order: %w", err) @@ -125,14 +115,10 @@ func (s *ACMEService) GetDNSChallenge(ctx context.Context, domain string) (*DNSC } for _, ch := range authz.Challenges { - if ch.Type == "dns-01" { + if ch.Type == "http-01" { challenge = ch break } - - if ch.Type == "http-01" { - return nil, fmt.Errorf("http-01 challenges are not supported") - } } if challenge != nil { @@ -141,27 +127,26 @@ func (s *ACMEService) GetDNSChallenge(ctx context.Context, domain string) (*DNSC } if challenge == nil { - return nil, fmt.Errorf("no DNS-01 challenge found") + return nil, fmt.Errorf("no HTTP-01 challenge found") } - recordValue, err := s.client.DNS01ChallengeRecord(challenge.Token) + keyAuth, err := s.client.HTTP01ChallengeResponse(challenge.Token) if err != nil { - return nil, fmt.Errorf("cannot get DNS record value: %w", err) + return nil, fmt.Errorf("cannot get challenge response: %w", err) } - return &DNSChallenge{ - Domain: domain, - RecordName: fmt.Sprintf("_acme-challenge.%s", domain), - RecordValue: recordValue, - Token: challenge.Token, - URL: challenge.URI, - OrderURL: order.URI, + return &HTTPChallenge{ + Domain: domain, + Token: challenge.Token, + KeyAuth: keyAuth, + URL: challenge.URI, + OrderURL: order.URI, }, nil } -func (s *ACMEService) CompleteDNSChallenge( +func (s *ACMEService) CompleteHTTPChallenge( ctx context.Context, - challenge0 *DNSChallenge, + challenge0 *HTTPChallenge, ) (*Certificate, error) { challenge1 := &acme.Challenge{ @@ -218,6 +203,22 @@ func (s *ACMEService) CompleteDNSChallenge( }, nil } +func (s *ACMEService) ObtainCertificate( + ctx context.Context, + domain string, +) (*Certificate, error) { + // For HTTP-01, we always need to serve the challenge + challenge, err := s.GetHTTPChallenge(ctx, domain) + if err != nil { + return nil, fmt.Errorf("cannot get HTTP challenge: %w", err) + } + + // The challenge token and key auth will be stored and served via HTTP + // The caller is responsible for ensuring the HTTP endpoint is ready + // before calling CompleteHTTPChallenge + return nil, fmt.Errorf("HTTP challenge ready: token=%s", challenge.Token) +} + func (s *ACMEService) RenewCertificate( ctx context.Context, domain string, @@ -227,23 +228,11 @@ func (s *ACMEService) RenewCertificate( return cert, nil } - // If renewal with existing auth fails, it might mean: - // 1. The authorization has expired (usually after 30-90 days of no renewal) - // 2. This is a first-time certificate request - // In these cases, we need a new challenge - s.logger.WarnCtx(ctx, "renewal with existing authorization failed, initiating new challenge", + s.logger.WarnCtx(ctx, "renewal with existing authorization failed, need new HTTP challenge", log.String("domain", domain), log.Error(err)) - challenge, err := s.GetDNSChallenge(ctx, domain) - if err != nil { - return nil, fmt.Errorf("cannot get DNS challenge for renewal: %w", err) - } - - return nil, &ErrDNSChallengeRequired{ - Domain: domain, - Challenge: challenge, - } + return s.ObtainCertificate(ctx, domain) } func (s *ACMEService) renewWithExistingAuth(ctx context.Context, domain string) (*Certificate, error) { diff --git a/pkg/cert/cache_warmer.go b/pkg/cert/cache_warmer.go index 6e12d1376..92faf79f4 100644 --- a/pkg/cert/cache_warmer.go +++ b/pkg/cert/cache_warmer.go @@ -53,7 +53,7 @@ func (w *CacheWarmer) WarmCache(ctx context.Context) error { ctx, func(conn pg.Conn) error { domains := coredata.CustomDomains{} - if err := domains.LoadActiveCertificates(ctx, conn, coredata.NewNoScope()); err != nil { + if err := domains.LoadActiveCertificates(ctx, conn, coredata.NewNoScope(), w.encryptionKey); err != nil { return fmt.Errorf("cannot load active certificates: %w", err) } diff --git a/pkg/cert/certificate_provisioner.go b/pkg/cert/certificate_provisioner.go new file mode 100644 index 000000000..4f1c46e69 --- /dev/null +++ b/pkg/cert/certificate_provisioner.go @@ -0,0 +1,186 @@ +// Copyright (c) 2025 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 +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cert + +import ( + "context" + "fmt" + "time" + + "github.com/getprobo/probo/pkg/coredata" + "github.com/getprobo/probo/pkg/crypto/cipher" + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" +) + +type ( + CertificateProvisioner struct { + pg *pg.Client + acmeService *ACMEService + encryptionKey cipher.EncryptionKey + interval time.Duration + logger *log.Logger + } +) + +func NewCertificateProvisioner( + pg *pg.Client, + acmeService *ACMEService, + encryptionKey cipher.EncryptionKey, + interval time.Duration, + logger *log.Logger, +) *CertificateProvisioner { + return &CertificateProvisioner{ + pg: pg, + acmeService: acmeService, + encryptionKey: encryptionKey, + interval: interval, + logger: logger.Named("cert.provisioner"), + } +} + +func (p *CertificateProvisioner) Run(ctx context.Context) error { + p.logger.InfoCtx(ctx, "certificate provisioner starting", log.Duration("interval", p.interval)) + + if err := p.checkPendingDomains(ctx); err != nil { + p.logger.ErrorCtx(ctx, "initial check failed", log.Error(err)) + } + + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + p.logger.InfoCtx(ctx, "certificate provisioner shutting down") + return ctx.Err() + case <-ticker.C: + if err := p.checkPendingDomains(ctx); err != nil { + p.logger.ErrorCtx(ctx, "periodic check failed", log.Error(err)) + } + } + } +} + +func (p *CertificateProvisioner) checkPendingDomains(ctx context.Context) error { + return p.pg.WithConn(ctx, func(conn pg.Conn) error { + var domains coredata.CustomDomains + if err := domains.ListDomainsWithPendingHTTPChallenges(ctx, conn, coredata.NewNoScope()); err != nil { + return fmt.Errorf("cannot load domains with pending challenges: %w", err) + } + + if len(domains) == 0 { + return nil + } + + p.logger.InfoCtx(ctx, "found domains with pending challenges", log.Int("count", len(domains))) + + for _, domain := range domains { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + if err := p.completeDomainCertificate(ctx, conn, domain); err != nil { + p.logger.ErrorCtx( + ctx, + "cannot complete certificate for domain", + log.String("domain", domain.Domain), + log.Error(err), + ) + } + } + + return nil + }) +} + +func (p *CertificateProvisioner) completeDomainCertificate( + ctx context.Context, + conn pg.Conn, + domain *coredata.CustomDomain, +) error { + challenge := &HTTPChallenge{ + Domain: domain.Domain, + Token: *domain.HTTPChallengeToken, + KeyAuth: *domain.HTTPChallengeKeyAuth, + URL: *domain.HTTPChallengeURL, + OrderURL: *domain.HTTPOrderURL, + } + + cert, err := p.acmeService.CompleteHTTPChallenge(ctx, challenge) + if err != nil { + p.logger.WarnCtx( + ctx, + "cannot complete HTTP challenge", + log.String("domain", domain.Domain), + log.Error(err), + ) + + return nil + } + + p.logger.InfoCtx( + ctx, + "certificate obtained successfully", + log.String("domain", domain.Domain), + log.Time("expires_at", cert.ExpiresAt), + ) + + scope := coredata.NewScope(domain.OrganizationID.TenantID()) + fullDomain := &coredata.CustomDomain{} + if err := fullDomain.LoadByID(ctx, conn, scope, p.encryptionKey, domain.ID); err != nil { + return fmt.Errorf("cannot load domain: %w", err) + } + + fullDomain.SSLCertificatePEM = cert.CertPEM + fullDomain.SSLPrivateKeyPEM = cert.KeyPEM + chainStr := string(cert.ChainPEM) + fullDomain.SSLCertificateChain = &chainStr + fullDomain.SSLExpiresAt = &cert.ExpiresAt + status := coredata.CustomDomainSSLStatusActive + fullDomain.SSLStatus = &status + + fullDomain.HTTPChallengeToken = nil + fullDomain.HTTPChallengeKeyAuth = nil + fullDomain.HTTPChallengeURL = nil + fullDomain.HTTPOrderURL = nil + + if err := fullDomain.Update(ctx, conn, scope, p.encryptionKey); err != nil { + return fmt.Errorf("cannot update domain: %w", err) + } + + cache := &coredata.CachedCertificate{ + Domain: fullDomain.Domain, + CertificatePEM: string(cert.CertPEM), + PrivateKeyPEM: string(cert.KeyPEM), + CertificateChain: &chainStr, + ExpiresAt: cert.ExpiresAt, + CachedAt: time.Now(), + CustomDomainID: fullDomain.ID, + } + + if err := cache.Upsert(ctx, conn); err != nil { + p.logger.ErrorCtx( + ctx, + "cannot update certificate cache", + log.String("domain", fullDomain.Domain), + log.Error(err), + ) + } + + return nil +} diff --git a/pkg/cert/certificate_renewer.go b/pkg/cert/certificate_renewer.go new file mode 100644 index 000000000..535352ba9 --- /dev/null +++ b/pkg/cert/certificate_renewer.go @@ -0,0 +1,223 @@ +// Copyright (c) 2025 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 +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cert + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/getprobo/probo/pkg/coredata" + "github.com/getprobo/probo/pkg/crypto/cipher" + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.gearno.de/x/ref" +) + +type ( + CertificateRenewer struct { + pg *pg.Client + acmeService *ACMEService + encryptionKey cipher.EncryptionKey + interval time.Duration + logger *log.Logger + } +) + +func NewCertificateRenewer( + pg *pg.Client, + acmeService *ACMEService, + encryptionKey cipher.EncryptionKey, + interval time.Duration, + logger *log.Logger, +) *CertificateRenewer { + return &CertificateRenewer{ + pg: pg, + acmeService: acmeService, + encryptionKey: encryptionKey, + interval: interval, + logger: logger.Named("cert.certificate-renewer"), + } +} + +func (r *CertificateRenewer) Run(ctx context.Context) error { + r.logger.InfoCtx(ctx, "certificate certificate-renewer starting") + + if err := r.checkAndRenew(ctx); err != nil { + r.logger.ErrorCtx(ctx, "cannot perform initial renewal check", log.Error(err)) + } + + for { + select { + case <-ctx.Done(): + r.logger.InfoCtx(ctx, "certificate certificate-renewer shutting down") + return ctx.Err() + case <-time.After(r.interval): + if err := r.checkAndRenew(ctx); err != nil { + r.logger.ErrorCtx(ctx, "cannot perform renewal check", log.Error(err)) + } + } + } +} + +func (r *CertificateRenewer) checkAndRenew(ctx context.Context) error { + return r.pg.WithConn( + ctx, + func(conn pg.Conn) error { + var caches coredata.CachedCertificates + cacheCount, err := caches.CountAll(ctx, conn) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot count certificate cache", log.Error(err)) + } else if cacheCount == 0 { + r.logger.InfoCtx(ctx, "certificate cache is empty, rebuilding from custom_domains") + + warmer := NewCacheWarmer(r.pg, r.encryptionKey, r.logger) + if err := warmer.WarmCache(ctx); err != nil { + r.logger.ErrorCtx(ctx, "cannot rebuild certificate cache", log.Error(err)) + } else { + r.logger.InfoCtx(ctx, "certificate cache rebuilt successfully") + } + } + + if err := caches.CleanExpired(ctx, conn); err != nil { + r.logger.ErrorCtx(ctx, "cannot clean certificate cache", log.Error(err)) + } + + domains := coredata.CustomDomains{} + scope := coredata.NewNoScope() + if err := domains.ListDomainsForRenewal(ctx, conn, scope); err != nil { + return fmt.Errorf("failed to list domains for renewal: %w", err) + } + + if len(domains) == 0 { + return nil + } + + r.logger.InfoCtx(ctx, "found domains needing renewal", log.Int("count", len(domains))) + + for _, domain := range domains { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + r.logger.InfoCtx(ctx, "renewing certificate for domain", log.String("domain", domain.Domain)) + if err := r.renewDomain(ctx, conn, domain); err != nil { + r.logger.ErrorCtx(ctx, "cannot renew certificate", log.String("domain", domain.Domain), log.Error(err)) + } else { + r.logger.InfoCtx(ctx, "successfully renewed certificate", log.String("domain", domain.Domain)) + } + } + + return nil + }, + ) +} + +func (r *CertificateRenewer) renewDomain(ctx context.Context, conn pg.Conn, domain *coredata.CustomDomain) error { + scope := coredata.NewScope(domain.OrganizationID.TenantID()) + + lockedDomain := &coredata.CustomDomain{} + if err := lockedDomain.LoadByIDForUpdate(ctx, conn, scope, r.encryptionKey, domain.ID); err != nil { + return fmt.Errorf("cannot lock domain for renewal: %w", err) + } + + if lockedDomain.SSLStatus == nil || *lockedDomain.SSLStatus != coredata.CustomDomainSSLStatusActive { + r.logger.InfoCtx( + ctx, + "domain status changed, skipping renewal", + log.String("domain", domain.Domain), + ) + + return nil + } + + cert, err := r.acmeService.RenewCertificate(ctx, lockedDomain.Domain) + if err != nil && strings.Contains(err.Error(), "HTTP challenge ready") { + challenge, err := r.acmeService.GetHTTPChallenge(ctx, lockedDomain.Domain) + if err != nil { + return fmt.Errorf("cannot get HTTP challenge for renewal: %w", err) + } + + r.logger.WarnCtx( + ctx, + "HTTP challenge required for renewal", + log.String("domain", lockedDomain.Domain), + log.String("token", challenge.Token), + ) + + lockedDomain.HTTPChallengeToken = &challenge.Token + lockedDomain.HTTPChallengeKeyAuth = &challenge.KeyAuth + lockedDomain.HTTPChallengeURL = &challenge.URL + lockedDomain.HTTPOrderURL = &challenge.OrderURL + lockedDomain.SSLStatus = ref.Ref(coredata.CustomDomainSSLStatusRenewing) + + if err := lockedDomain.Update(ctx, conn, scope, r.encryptionKey); err != nil { + return fmt.Errorf("cannot update domain with renewal challenge: %w", err) + } + + return nil + } + + if err != nil { + return fmt.Errorf("cannot renew certificate: %w", err) + } + + r.logger.InfoCtx( + ctx, + "certificate renewed successfully", + log.String("domain", lockedDomain.Domain), + log.Time("expires_at", cert.ExpiresAt), + ) + + lockedDomain.SSLCertificatePEM = cert.CertPEM + lockedDomain.SSLPrivateKeyPEM = cert.KeyPEM + chainStr := string(cert.ChainPEM) + lockedDomain.SSLCertificateChain = &chainStr + lockedDomain.SSLExpiresAt = &cert.ExpiresAt + lockedDomain.SSLStatus = ref.Ref(coredata.CustomDomainSSLStatusActive) + + lockedDomain.HTTPChallengeToken = nil + lockedDomain.HTTPChallengeKeyAuth = nil + lockedDomain.HTTPChallengeURL = nil + lockedDomain.HTTPOrderURL = nil + + if err := lockedDomain.Update(ctx, conn, scope, r.encryptionKey); err != nil { + return fmt.Errorf("cannot update domain with renewed certificate: %w", err) + } + + cache := &coredata.CachedCertificate{ + Domain: lockedDomain.Domain, + CertificatePEM: string(cert.CertPEM), + PrivateKeyPEM: string(cert.KeyPEM), + CertificateChain: &chainStr, + ExpiresAt: cert.ExpiresAt, + CachedAt: time.Now(), + CustomDomainID: lockedDomain.ID, + } + + if err := cache.Upsert(ctx, conn); err != nil { + r.logger.ErrorCtx( + ctx, + "cannot update certificate cache", + log.String("domain", domain.Domain), + log.Error(err), + ) + } + + return nil +} diff --git a/pkg/cert/selector.go b/pkg/cert/selector.go new file mode 100644 index 000000000..519d47259 --- /dev/null +++ b/pkg/cert/selector.go @@ -0,0 +1,185 @@ +// Copyright (c) 2025 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 +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cert + +import ( + "context" + "crypto/tls" + "fmt" + "sync" + "time" + + "github.com/getprobo/probo/pkg/coredata" + "github.com/getprobo/probo/pkg/crypto/cipher" + "go.gearno.de/kit/pg" +) + +type ( + Selector struct { + pg *pg.Client + cache sync.Map + defaultDomain string + encryptionKey cipher.EncryptionKey + defaultCert *tls.Certificate + defaultCertMutex sync.RWMutex + } +) + +func NewSelector( + pg *pg.Client, + defaultDomain string, + encryptionKey cipher.EncryptionKey, +) *Selector { + return &Selector{ + pg: pg, + defaultDomain: defaultDomain, + encryptionKey: encryptionKey, + } +} + +func (s *Selector) SetDefaultCertificate(cert *tls.Certificate) { + s.defaultCertMutex.Lock() + defer s.defaultCertMutex.Unlock() + s.defaultCert = cert +} + +func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + domain := hello.ServerName + + // Empty domain, use default + if domain == "" { + return s.getDefaultCertificate() + } + + if cached, ok := s.cache.Load(domain); ok { + if cert, ok := cached.(*tls.Certificate); ok { + return cert, nil + } + } + + cert, err := s.loadFromDatabase(domain) + if err != nil { + return s.getDefaultCertificate() + } + + s.cache.Store(domain, cert) + return cert, nil +} + +func (s *Selector) loadFromDatabase(domain string) (*tls.Certificate, error) { + ctx := context.Background() + + var cert *tls.Certificate + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + var cache coredata.CachedCertificate + if err := cache.LoadByDomain(ctx, conn, domain); err != nil { + if err := s.rebuildCacheEntry(ctx, conn, domain); err != nil { + return fmt.Errorf("cannot rebuild cache entry: %w", err) + } + + if err := cache.LoadByDomain(ctx, conn, domain); err != nil { + return fmt.Errorf("cannot load certificate from cache after rebuild: %w", err) + } + } + + fullCertPEM := cache.CertificatePEM + if cache.CertificateChain != nil { + fullCertPEM += "\n" + *cache.CertificateChain + } + + tlsCert, err := tls.X509KeyPair([]byte(fullCertPEM), []byte(cache.PrivateKeyPEM)) + if err != nil { + return fmt.Errorf("cannot parse certificate: %w", err) + } + + cert = &tlsCert + return nil + }, + ) + + if err != nil { + return nil, err + } + + return cert, nil +} + +func (s *Selector) rebuildCacheEntry(ctx context.Context, conn pg.Conn, domain string) error { + var customDomain coredata.CustomDomain + if err := customDomain.LoadByDomain(ctx, conn, coredata.NewNoScope(), s.encryptionKey, domain); err != nil { + return fmt.Errorf("cannot load domain: %w", err) + } + + if !customDomain.IsActive { + return fmt.Errorf("domain is not active") + } + + if customDomain.SSLStatus == nil || *customDomain.SSLStatus != coredata.CustomDomainSSLStatusActive { + return fmt.Errorf("domain does not have active SSL certificate") + } + + if customDomain.SSLCertificate == nil { + return fmt.Errorf("domain has no parsed certificate") + } + + if len(customDomain.SSLCertificatePEM) == 0 { + return fmt.Errorf("domain has no certificate PEM data") + } + + if len(customDomain.SSLPrivateKeyPEM) == 0 { + return fmt.Errorf("domain has no private key PEM data") + } + + s.cache.Store(domain, customDomain.SSLCertificate) + + cache := &coredata.CachedCertificate{ + Domain: customDomain.Domain, + CertificatePEM: string(customDomain.SSLCertificatePEM), + PrivateKeyPEM: string(customDomain.SSLPrivateKeyPEM), + CertificateChain: customDomain.SSLCertificateChain, + ExpiresAt: *customDomain.SSLExpiresAt, + CachedAt: time.Now(), + CustomDomainID: customDomain.ID, + } + + if err := cache.Upsert(ctx, conn); err != nil { + return fmt.Errorf("failed to insert cache entry: %w", err) + } + + return nil +} + +// getDefaultCertificate returns the default wildcard certificate +func (s *Selector) getDefaultCertificate() (*tls.Certificate, error) { + s.defaultCertMutex.RLock() + defer s.defaultCertMutex.RUnlock() + + if s.defaultCert == nil { + return nil, fmt.Errorf("no default certificate configured") + } + + return s.defaultCert, nil +} + +func (s *Selector) ClearCache() { + s.cache.Range( + func(key, _ any) bool { + s.cache.Delete(key) + return true + }, + ) +} diff --git a/pkg/cert/utils.go b/pkg/cert/utils.go new file mode 100644 index 000000000..c87b091bd --- /dev/null +++ b/pkg/cert/utils.go @@ -0,0 +1,24 @@ +// Copyright (c) 2025 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 +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package cert + +// DNSRecordInstruction provides DNS configuration instructions +type DNSRecordInstruction struct { + Type string `json:"type"` // TXT, CNAME + Name string `json:"name"` // Full DNS record name + Value string `json:"value"` // Value to set + TTL int `json:"ttl"` // Recommended TTL + Purpose string `json:"purpose"` // verification, acme_challenge +} \ No newline at end of file