Rename pkg in certmanager

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-09-30 21:43:43 +02:00
parent fe58eb527e
commit 6ff8386818
7 changed files with 162 additions and 94 deletions

317
pkg/certmanager/acme.go Normal file
View File

@@ -0,0 +1,317 @@
// 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 certmanager
import (
"context"
"crypto"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"net/http"
"time"
"github.com/getprobo/probo/pkg/crypto/keys"
"github.com/getprobo/probo/pkg/crypto/pem"
"github.com/getprobo/probo/pkg/version"
"go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log"
"golang.org/x/crypto/acme"
)
type (
Certificate struct {
CertPEM []byte
KeyPEM []byte
ChainPEM []byte
ExpiresAt time.Time
}
ACMEService struct {
client *acme.Client
email string
keyType keys.Type
logger *log.Logger
}
HTTPChallenge struct {
Domain string
Token string
KeyAuth string
URL string
OrderURL string
}
)
func NewACMEService(email string, keyType keys.Type, directoryURL string, insecureTLS bool, logger *log.Logger) (*ACMEService, error) {
accountKey, err := keys.Generate(keyType)
if err != nil {
return nil, fmt.Errorf("cannot generate account key: %w", err)
}
var httpClient *http.Client
if insecureTLS {
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
httpClient = &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
logger.Warn("ACME service configured with insecure TLS - use only for local testing")
} else {
httpClient = httpclient.DefaultPooledClient(
httpclient.WithLogger(logger),
)
}
client := &acme.Client{
Key: accountKey,
DirectoryURL: directoryURL,
UserAgent: version.UserAgent("acme"),
HTTPClient: httpClient,
}
service := &ACMEService{
client: client,
email: email,
keyType: keyType,
logger: logger.Named("acme"),
}
ctx := context.Background()
if err := service.registerAccount(ctx); err != nil {
return nil, fmt.Errorf("cannot register ACME account: %w", err)
}
return service, nil
}
func (s *ACMEService) registerAccount(ctx context.Context) error {
account := &acme.Account{Contact: []string{"mailto:" + s.email}}
if _, err := s.client.Register(ctx, account, acme.AcceptTOS); err != nil {
if err != acme.ErrAccountAlreadyExists {
return fmt.Errorf("cannot register account: %w", err)
}
}
return nil
}
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)
}
var challenge *acme.Challenge
for _, auth := range order.AuthzURLs {
authz, err := s.client.GetAuthorization(ctx, auth)
if err != nil {
return nil, fmt.Errorf("cannot get authorization: %w", err)
}
for _, ch := range authz.Challenges {
if ch.Type == "http-01" {
challenge = ch
break
}
}
if challenge != nil {
break
}
}
if challenge == nil {
return nil, fmt.Errorf("no HTTP-01 challenge found")
}
keyAuth, err := s.client.HTTP01ChallengeResponse(challenge.Token)
if err != nil {
return nil, fmt.Errorf("cannot get challenge response: %w", err)
}
return &HTTPChallenge{
Domain: domain,
Token: challenge.Token,
KeyAuth: keyAuth,
URL: challenge.URI,
OrderURL: order.URI,
}, nil
}
func (s *ACMEService) CompleteHTTPChallenge(
ctx context.Context,
challenge0 *HTTPChallenge,
) (*Certificate, error) {
challenge1 := &acme.Challenge{
URI: challenge0.URL,
Token: challenge0.Token,
}
if _, err := s.client.Accept(ctx, challenge1); err != nil {
return nil, fmt.Errorf("cannot accept challenge: %w", err)
}
order, err := s.client.WaitOrder(ctx, challenge0.OrderURL)
if err != nil {
return nil, fmt.Errorf("cannot wait for order: %w", err)
}
certKey, err := keys.Generate(s.keyType)
if err != nil {
return nil, fmt.Errorf("cannot generate certificate key: %w", err)
}
csr, err := createCSR(challenge0.Domain, certKey)
if err != nil {
return nil, fmt.Errorf("cannot create CSR: %w", err)
}
der, _, err := s.client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
if err != nil {
return nil, fmt.Errorf("cannot create certificate: %w", err)
}
cert, err := x509.ParseCertificate(der[0])
if err != nil {
return nil, fmt.Errorf("cannot parse certificate: %w", err)
}
certPEM := pem.EncodeCertificate(der[0])
keyPEM, err := pem.EncodePrivateKey(certKey)
if err != nil {
return nil, fmt.Errorf("cannot encode key: %w", err)
}
var chainDER [][]byte
if len(der) > 1 {
chainDER = der[1:]
}
chainPEM := pem.EncodeCertificateChain(chainDER)
return &Certificate{
CertPEM: certPEM,
KeyPEM: keyPEM,
ChainPEM: chainPEM,
ExpiresAt: cert.NotAfter,
}, 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,
) (*Certificate, error) {
cert, err := s.renewWithExistingAuth(ctx, domain)
if err == nil {
return cert, nil
}
s.logger.WarnCtx(ctx, "renewal with existing authorization failed, need new HTTP challenge",
log.String("domain", domain),
log.Error(err))
return s.ObtainCertificate(ctx, domain)
}
func (s *ACMEService) renewWithExistingAuth(ctx context.Context, domain string) (*Certificate, error) {
order, err := s.client.AuthorizeOrder(ctx, acme.DomainIDs(domain))
if err != nil {
return nil, fmt.Errorf("cannot create renewal order: %w", err)
}
if order.Status != acme.StatusReady {
order, err = s.client.WaitOrder(ctx, order.URI)
if err != nil {
return nil, fmt.Errorf("authorization not valid or expired: %w", err)
}
}
certKey, err := keys.Generate(s.keyType)
if err != nil {
return nil, fmt.Errorf("cannot generate certificate key: %w", err)
}
csr, err := createCSR(domain, certKey)
if err != nil {
return nil, fmt.Errorf("cannot create CSR: %w", err)
}
der, _, err := s.client.CreateOrderCert(ctx, order.FinalizeURL, csr, true)
if err != nil {
return nil, fmt.Errorf("cannot create certificate: %w", err)
}
cert, err := x509.ParseCertificate(der[0])
if err != nil {
return nil, fmt.Errorf("cannot parse certificate: %w", err)
}
certPEM := pem.EncodeCertificate(der[0])
keyPEM, err := pem.EncodePrivateKey(certKey)
if err != nil {
return nil, fmt.Errorf("cannot encode key: %w", err)
}
var chainDER [][]byte
if len(der) > 1 {
chainDER = der[1:]
}
chainPEM := pem.EncodeCertificateChain(chainDER)
return &Certificate{
CertPEM: certPEM,
KeyPEM: keyPEM,
ChainPEM: chainPEM,
ExpiresAt: cert.NotAfter,
}, nil
}
func (s *ACMEService) CheckRenewalNeeded(expiresAt time.Time, threshold time.Duration) bool {
return time.Until(expiresAt) <= threshold
}
func createCSR(domain string, key crypto.Signer) ([]byte, error) {
template := &x509.CertificateRequest{
Subject: pkix.Name{
CommonName: domain,
},
DNSNames: []string{domain},
}
return x509.CreateCertificateRequest(rand.Reader, template, key)
}

View File

@@ -0,0 +1,100 @@
// 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 certmanager
import (
"context"
"net/http"
"strings"
"github.com/getprobo/probo/pkg/coredata"
"github.com/getprobo/probo/pkg/crypto/cipher"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
)
type ACMEChallengeHandler struct {
pg *pg.Client
encryptionKey cipher.EncryptionKey
logger *log.Logger
}
func NewACMEChallengeHandler(
pg *pg.Client,
encryptionKey cipher.EncryptionKey,
logger *log.Logger,
) *ACMEChallengeHandler {
return &ACMEChallengeHandler{
pg: pg,
encryptionKey: encryptionKey,
logger: logger.Named("acme-challenge-handler"),
}
}
func (h *ACMEChallengeHandler) Handle(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/.well-known/acme-challenge/") {
next.ServeHTTP(w, r)
return
}
token := strings.TrimPrefix(r.URL.Path, "/.well-known/acme-challenge/")
if token == "" {
http.NotFound(w, r)
return
}
keyAuth, err := h.getKeyAuthForToken(r.Context(), token)
if err != nil {
h.logger.WarnCtx(
r.Context(),
"cannot get key auth for token",
log.String("token", token),
log.Error(err),
)
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte(keyAuth))
})
}
func (h *ACMEChallengeHandler) getKeyAuthForToken(ctx context.Context, token string) (string, error) {
var keyAuth string
err := h.pg.WithConn(
ctx,
func(conn pg.Conn) error {
domain := &coredata.CustomDomain{}
if err := domain.LoadByHTTPChallengeToken(ctx, conn, coredata.NewNoScope(), h.encryptionKey, token); err != nil {
return err
}
if domain.HTTPChallengeKeyAuth == nil {
return http.ErrNotSupported
}
keyAuth = *domain.HTTPChallengeKeyAuth
return nil
},
)
return keyAuth, err
}

View File

@@ -0,0 +1,168 @@
// 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 certmanager
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 (
CacheStore struct {
pg *pg.Client
encryptionKey cipher.EncryptionKey
logger *log.Logger
}
)
func NewCacheStore(
pg *pg.Client,
encryptionKey cipher.EncryptionKey,
logger *log.Logger,
) *CacheStore {
return &CacheStore{
pg: pg,
encryptionKey: encryptionKey,
logger: logger.Named("certmanager.cache-store"),
}
}
func (w *CacheStore) WarmCache(ctx context.Context) error {
w.logger.InfoCtx(ctx, "warming certificate cache")
startTime := time.Now()
err := w.pg.WithConn(
ctx,
func(conn pg.Conn) error {
domains := coredata.CustomDomains{}
if err := domains.LoadActiveCertificates(ctx, conn, coredata.NewNoScope(), w.encryptionKey); err != nil {
return fmt.Errorf("cannot load active certificates: %w", err)
}
if len(domains) == 0 {
w.logger.InfoCtx(ctx, "no active certificates to warm")
return nil
}
w.logger.InfoCtx(ctx, "found active certificates to cache", log.Int("count", len(domains)))
successCount := 0
for _, domain := range domains {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := w.warmDomain(ctx, conn, domain); err != nil {
w.logger.ErrorCtx(ctx, "cannot warm certificate cache for domain", log.String("domain", domain.Domain), log.Error(err))
} else {
successCount++
}
}
w.logger.InfoCtx(ctx, "successfully warmed cache", log.Int("success_count", successCount), log.Int("total_count", len(domains)))
return nil
},
)
if err != nil {
return fmt.Errorf("cannot warm certificate cache: %w", err)
}
w.logger.InfoCtx(ctx, "certificate cache warming completed", log.Duration("duration", time.Since(startTime)))
return nil
}
func (w *CacheStore) warmDomain(ctx context.Context, conn pg.Conn, domain *coredata.CustomDomain) error {
var loadedDomain coredata.CustomDomain
scope := coredata.NewScope(domain.OrganizationID.TenantID())
if err := loadedDomain.LoadByID(ctx, conn, scope, w.encryptionKey, domain.ID); err != nil {
return fmt.Errorf("cannot load domain with decrypted values: %w", err)
}
if loadedDomain.SSLCertificate == nil {
return fmt.Errorf("domain has no parsed certificate")
}
if len(loadedDomain.SSLCertificatePEM) == 0 {
return fmt.Errorf("domain has no certificate PEM")
}
if len(loadedDomain.SSLPrivateKeyPEM) == 0 {
return fmt.Errorf("domain has no private key PEM")
}
if loadedDomain.SSLExpiresAt == nil {
return fmt.Errorf("domain certificate has no expiry date")
}
if time.Now().After(*loadedDomain.SSLExpiresAt) {
return fmt.Errorf("certificate has expired")
}
cache := &coredata.CachedCertificate{
Domain: loadedDomain.Domain,
CertificatePEM: string(loadedDomain.SSLCertificatePEM),
PrivateKeyPEM: string(loadedDomain.SSLPrivateKeyPEM),
CertificateChain: loadedDomain.SSLCertificateChain,
ExpiresAt: *loadedDomain.SSLExpiresAt,
CachedAt: time.Now(),
CustomDomainID: loadedDomain.ID,
}
if err := cache.Upsert(ctx, conn); err != nil {
return fmt.Errorf("cannot upsert cache entry: %w", err)
}
return nil
}
func (w *CacheStore) RefreshCache(ctx context.Context) error {
w.logger.InfoCtx(ctx, "refreshing certificate cache")
return w.pg.WithConn(
ctx,
func(conn pg.Conn) error {
cachedCertificates := coredata.CachedCertificates{}
if err := cachedCertificates.CleanExpired(ctx, conn); err != nil {
w.logger.ErrorCtx(ctx, "cannot clean expired cache", log.Error(err))
}
return w.WarmCache(ctx)
},
)
}
func (w *CacheStore) WarmSingleDomain(ctx context.Context, domainName string) error {
return w.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var domain coredata.CustomDomain
if err := domain.LoadByDomain(ctx, conn, coredata.NewNoScope(), w.encryptionKey, domainName); err != nil {
return fmt.Errorf("cannot load domain: %w", err)
}
return w.warmDomain(ctx, conn, &domain)
},
)
}

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 certmanager
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 (
Provisioner struct {
pg *pg.Client
acmeService *ACMEService
encryptionKey cipher.EncryptionKey
interval time.Duration
logger *log.Logger
}
)
func NewProvisioner(
pg *pg.Client,
acmeService *ACMEService,
encryptionKey cipher.EncryptionKey,
interval time.Duration,
logger *log.Logger,
) *Provisioner {
return &Provisioner{
pg: pg,
acmeService: acmeService,
encryptionKey: encryptionKey,
interval: interval,
logger: logger.Named("certmanager.provisioner"),
}
}
func (p *Provisioner) 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 *Provisioner) 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 *Provisioner) 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
}

223
pkg/certmanager/renewer.go Normal file
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 certmanager
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 (
Renewer struct {
pg *pg.Client
acmeService *ACMEService
encryptionKey cipher.EncryptionKey
interval time.Duration
logger *log.Logger
}
)
func NewRenewer(
pg *pg.Client,
acmeService *ACMEService,
encryptionKey cipher.EncryptionKey,
interval time.Duration,
logger *log.Logger,
) *Renewer {
return &Renewer{
pg: pg,
acmeService: acmeService,
encryptionKey: encryptionKey,
interval: interval,
logger: logger.Named("certmanager.renewer"),
}
}
func (r *Renewer) Run(ctx context.Context) error {
r.logger.InfoCtx(ctx, "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 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 *Renewer) 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 := NewCacheStore(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 *Renewer) 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
}

164
pkg/certmanager/selector.go Normal file
View File

@@ -0,0 +1,164 @@
// 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 certmanager
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
encryptionKey cipher.EncryptionKey
}
)
func NewSelector(
pg *pg.Client,
encryptionKey cipher.EncryptionKey,
) *Selector {
return &Selector{
pg: pg,
encryptionKey: encryptionKey,
}
}
func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
domain := hello.ServerName
// Empty domain, return error
if domain == "" {
return nil, fmt.Errorf("no SNI provided")
}
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 nil, err
}
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
}
func (s *Selector) ClearCache() {
s.cache.Range(
func(key, _ any) bool {
s.cache.Delete(key)
return true
},
)
}