Add ACME workers

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-09-30 16:37:59 +02:00
parent 49f4147e4a
commit eb11eb0970
6 changed files with 656 additions and 49 deletions

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -0,0 +1,186 @@
// Copyright (c) 2025 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
// 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
}

View File

@@ -0,0 +1,223 @@
// Copyright (c) 2025 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
// 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
}

185
pkg/cert/selector.go Normal file
View File

@@ -0,0 +1,185 @@
// Copyright (c) 2025 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
// 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
},
)
}

24
pkg/cert/utils.go Normal file
View File

@@ -0,0 +1,24 @@
// Copyright (c) 2025 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
// 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
}