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

View File

@@ -1,24 +0,0 @@
// 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
}

View File

@@ -12,19 +12,21 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
package cert package certmanager
import ( import (
"context" "context"
"crypto" "crypto"
"crypto/rand" "crypto/rand"
"crypto/tls"
"crypto/x509" "crypto/x509"
"crypto/x509/pkix" "crypto/x509/pkix"
"fmt" "fmt"
"net/http"
"time" "time"
"github.com/getprobo/probo/pkg/crypto/keys" "github.com/getprobo/probo/pkg/crypto/keys"
cryptopem "github.com/getprobo/probo/pkg/crypto/pem" "github.com/getprobo/probo/pkg/crypto/pem"
"github.com/getprobo/probo/pkg/version" "github.com/getprobo/probo/pkg/version"
"go.gearno.de/kit/httpclient" "go.gearno.de/kit/httpclient"
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
@@ -55,17 +57,28 @@ type (
} }
) )
func NewACMEService(email string, keyType keys.Type, directoryURL string, logger *log.Logger) (*ACMEService, error) { func NewACMEService(email string, keyType keys.Type, directoryURL string, insecureTLS bool, logger *log.Logger) (*ACMEService, error) {
accountKey, err := keys.Generate(keyType) accountKey, err := keys.Generate(keyType)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot generate account key: %w", err) return nil, fmt.Errorf("cannot generate account key: %w", err)
} }
httpClient := httpclient.DefaultPooledClient( var httpClient *http.Client
httpclient.WithLogger(logger),
// httpclient.WithTracerProvider(tp), if insecureTLS {
// httpclient.WithRegisterer(r), 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{ client := &acme.Client{
Key: accountKey, Key: accountKey,
@@ -183,8 +196,8 @@ func (s *ACMEService) CompleteHTTPChallenge(
return nil, fmt.Errorf("cannot parse certificate: %w", err) return nil, fmt.Errorf("cannot parse certificate: %w", err)
} }
certPEM := cryptopem.EncodeCertificate(der[0]) certPEM := pem.EncodeCertificate(der[0])
keyPEM, err := cryptopem.EncodePrivateKey(certKey) keyPEM, err := pem.EncodePrivateKey(certKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot encode key: %w", err) return nil, fmt.Errorf("cannot encode key: %w", err)
} }
@@ -193,7 +206,7 @@ func (s *ACMEService) CompleteHTTPChallenge(
if len(der) > 1 { if len(der) > 1 {
chainDER = der[1:] chainDER = der[1:]
} }
chainPEM := cryptopem.EncodeCertificateChain(chainDER) chainPEM := pem.EncodeCertificateChain(chainDER)
return &Certificate{ return &Certificate{
CertPEM: certPEM, CertPEM: certPEM,
@@ -268,8 +281,8 @@ func (s *ACMEService) renewWithExistingAuth(ctx context.Context, domain string)
return nil, fmt.Errorf("cannot parse certificate: %w", err) return nil, fmt.Errorf("cannot parse certificate: %w", err)
} }
certPEM := cryptopem.EncodeCertificate(der[0]) certPEM := pem.EncodeCertificate(der[0])
keyPEM, err := cryptopem.EncodePrivateKey(certKey) keyPEM, err := pem.EncodePrivateKey(certKey)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot encode key: %w", err) return nil, fmt.Errorf("cannot encode key: %w", err)
} }
@@ -278,7 +291,7 @@ func (s *ACMEService) renewWithExistingAuth(ctx context.Context, domain string)
if len(der) > 1 { if len(der) > 1 {
chainDER = der[1:] chainDER = der[1:]
} }
chainPEM := cryptopem.EncodeCertificateChain(chainDER) chainPEM := pem.EncodeCertificateChain(chainDER)
return &Certificate{ return &Certificate{
CertPEM: certPEM, CertPEM: certPEM,

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

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
package cert package certmanager
import ( import (
"context" "context"
@@ -26,26 +26,26 @@ import (
) )
type ( type (
CacheWarmer struct { CacheStore struct {
pg *pg.Client pg *pg.Client
encryptionKey cipher.EncryptionKey encryptionKey cipher.EncryptionKey
logger *log.Logger logger *log.Logger
} }
) )
func NewCacheWarmer( func NewCacheStore(
pg *pg.Client, pg *pg.Client,
encryptionKey cipher.EncryptionKey, encryptionKey cipher.EncryptionKey,
logger *log.Logger, logger *log.Logger,
) *CacheWarmer { ) *CacheStore {
return &CacheWarmer{ return &CacheStore{
pg: pg, pg: pg,
encryptionKey: encryptionKey, encryptionKey: encryptionKey,
logger: logger.Named("cert.cache_warmer"), logger: logger.Named("certmanager.cache-store"),
} }
} }
func (w *CacheWarmer) WarmCache(ctx context.Context) error { func (w *CacheStore) WarmCache(ctx context.Context) error {
w.logger.InfoCtx(ctx, "warming certificate cache") w.logger.InfoCtx(ctx, "warming certificate cache")
startTime := time.Now() startTime := time.Now()
@@ -93,7 +93,7 @@ func (w *CacheWarmer) WarmCache(ctx context.Context) error {
return nil return nil
} }
func (w *CacheWarmer) warmDomain(ctx context.Context, conn pg.Conn, domain *coredata.CustomDomain) error { func (w *CacheStore) warmDomain(ctx context.Context, conn pg.Conn, domain *coredata.CustomDomain) error {
var loadedDomain coredata.CustomDomain var loadedDomain coredata.CustomDomain
scope := coredata.NewScope(domain.OrganizationID.TenantID()) scope := coredata.NewScope(domain.OrganizationID.TenantID())
if err := loadedDomain.LoadByID(ctx, conn, scope, w.encryptionKey, domain.ID); err != nil { if err := loadedDomain.LoadByID(ctx, conn, scope, w.encryptionKey, domain.ID); err != nil {
@@ -137,7 +137,7 @@ func (w *CacheWarmer) warmDomain(ctx context.Context, conn pg.Conn, domain *core
return nil return nil
} }
func (w *CacheWarmer) RefreshCache(ctx context.Context) error { func (w *CacheStore) RefreshCache(ctx context.Context) error {
w.logger.InfoCtx(ctx, "refreshing certificate cache") w.logger.InfoCtx(ctx, "refreshing certificate cache")
return w.pg.WithConn( return w.pg.WithConn(
@@ -153,7 +153,7 @@ func (w *CacheWarmer) RefreshCache(ctx context.Context) error {
) )
} }
func (w *CacheWarmer) WarmSingleDomain(ctx context.Context, domainName string) error { func (w *CacheStore) WarmSingleDomain(ctx context.Context, domainName string) error {
return w.pg.WithConn( return w.pg.WithConn(
ctx, ctx,
func(conn pg.Conn) error { func(conn pg.Conn) error {

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
package cert package certmanager
import ( import (
"context" "context"
@@ -26,7 +26,7 @@ import (
) )
type ( type (
CertificateProvisioner struct { Provisioner struct {
pg *pg.Client pg *pg.Client
acmeService *ACMEService acmeService *ACMEService
encryptionKey cipher.EncryptionKey encryptionKey cipher.EncryptionKey
@@ -35,23 +35,23 @@ type (
} }
) )
func NewCertificateProvisioner( func NewProvisioner(
pg *pg.Client, pg *pg.Client,
acmeService *ACMEService, acmeService *ACMEService,
encryptionKey cipher.EncryptionKey, encryptionKey cipher.EncryptionKey,
interval time.Duration, interval time.Duration,
logger *log.Logger, logger *log.Logger,
) *CertificateProvisioner { ) *Provisioner {
return &CertificateProvisioner{ return &Provisioner{
pg: pg, pg: pg,
acmeService: acmeService, acmeService: acmeService,
encryptionKey: encryptionKey, encryptionKey: encryptionKey,
interval: interval, interval: interval,
logger: logger.Named("cert.provisioner"), logger: logger.Named("certmanager.provisioner"),
} }
} }
func (p *CertificateProvisioner) Run(ctx context.Context) error { func (p *Provisioner) Run(ctx context.Context) error {
p.logger.InfoCtx(ctx, "certificate provisioner starting", log.Duration("interval", p.interval)) p.logger.InfoCtx(ctx, "certificate provisioner starting", log.Duration("interval", p.interval))
if err := p.checkPendingDomains(ctx); err != nil { if err := p.checkPendingDomains(ctx); err != nil {
@@ -74,7 +74,7 @@ func (p *CertificateProvisioner) Run(ctx context.Context) error {
} }
} }
func (p *CertificateProvisioner) checkPendingDomains(ctx context.Context) error { func (p *Provisioner) checkPendingDomains(ctx context.Context) error {
return p.pg.WithConn(ctx, func(conn pg.Conn) error { return p.pg.WithConn(ctx, func(conn pg.Conn) error {
var domains coredata.CustomDomains var domains coredata.CustomDomains
if err := domains.ListDomainsWithPendingHTTPChallenges(ctx, conn, coredata.NewNoScope()); err != nil { if err := domains.ListDomainsWithPendingHTTPChallenges(ctx, conn, coredata.NewNoScope()); err != nil {
@@ -108,7 +108,7 @@ func (p *CertificateProvisioner) checkPendingDomains(ctx context.Context) error
}) })
} }
func (p *CertificateProvisioner) completeDomainCertificate( func (p *Provisioner) completeDomainCertificate(
ctx context.Context, ctx context.Context,
conn pg.Conn, conn pg.Conn,
domain *coredata.CustomDomain, domain *coredata.CustomDomain,

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
package cert package certmanager
import ( import (
"context" "context"
@@ -28,7 +28,7 @@ import (
) )
type ( type (
CertificateRenewer struct { Renewer struct {
pg *pg.Client pg *pg.Client
acmeService *ACMEService acmeService *ACMEService
encryptionKey cipher.EncryptionKey encryptionKey cipher.EncryptionKey
@@ -37,24 +37,24 @@ type (
} }
) )
func NewCertificateRenewer( func NewRenewer(
pg *pg.Client, pg *pg.Client,
acmeService *ACMEService, acmeService *ACMEService,
encryptionKey cipher.EncryptionKey, encryptionKey cipher.EncryptionKey,
interval time.Duration, interval time.Duration,
logger *log.Logger, logger *log.Logger,
) *CertificateRenewer { ) *Renewer {
return &CertificateRenewer{ return &Renewer{
pg: pg, pg: pg,
acmeService: acmeService, acmeService: acmeService,
encryptionKey: encryptionKey, encryptionKey: encryptionKey,
interval: interval, interval: interval,
logger: logger.Named("cert.certificate-renewer"), logger: logger.Named("certmanager.renewer"),
} }
} }
func (r *CertificateRenewer) Run(ctx context.Context) error { func (r *Renewer) Run(ctx context.Context) error {
r.logger.InfoCtx(ctx, "certificate certificate-renewer starting") r.logger.InfoCtx(ctx, "certificate renewer starting")
if err := r.checkAndRenew(ctx); err != nil { if err := r.checkAndRenew(ctx); err != nil {
r.logger.ErrorCtx(ctx, "cannot perform initial renewal check", log.Error(err)) r.logger.ErrorCtx(ctx, "cannot perform initial renewal check", log.Error(err))
@@ -63,7 +63,7 @@ func (r *CertificateRenewer) Run(ctx context.Context) error {
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
r.logger.InfoCtx(ctx, "certificate certificate-renewer shutting down") r.logger.InfoCtx(ctx, "certificate renewer shutting down")
return ctx.Err() return ctx.Err()
case <-time.After(r.interval): case <-time.After(r.interval):
if err := r.checkAndRenew(ctx); err != nil { if err := r.checkAndRenew(ctx); err != nil {
@@ -73,7 +73,7 @@ func (r *CertificateRenewer) Run(ctx context.Context) error {
} }
} }
func (r *CertificateRenewer) checkAndRenew(ctx context.Context) error { func (r *Renewer) checkAndRenew(ctx context.Context) error {
return r.pg.WithConn( return r.pg.WithConn(
ctx, ctx,
func(conn pg.Conn) error { func(conn pg.Conn) error {
@@ -84,7 +84,7 @@ func (r *CertificateRenewer) checkAndRenew(ctx context.Context) error {
} else if cacheCount == 0 { } else if cacheCount == 0 {
r.logger.InfoCtx(ctx, "certificate cache is empty, rebuilding from custom_domains") r.logger.InfoCtx(ctx, "certificate cache is empty, rebuilding from custom_domains")
warmer := NewCacheWarmer(r.pg, r.encryptionKey, r.logger) warmer := NewCacheStore(r.pg, r.encryptionKey, r.logger)
if err := warmer.WarmCache(ctx); err != nil { if err := warmer.WarmCache(ctx); err != nil {
r.logger.ErrorCtx(ctx, "cannot rebuild certificate cache", log.Error(err)) r.logger.ErrorCtx(ctx, "cannot rebuild certificate cache", log.Error(err))
} else { } else {
@@ -128,7 +128,7 @@ func (r *CertificateRenewer) checkAndRenew(ctx context.Context) error {
) )
} }
func (r *CertificateRenewer) renewDomain(ctx context.Context, conn pg.Conn, domain *coredata.CustomDomain) error { func (r *Renewer) renewDomain(ctx context.Context, conn pg.Conn, domain *coredata.CustomDomain) error {
scope := coredata.NewScope(domain.OrganizationID.TenantID()) scope := coredata.NewScope(domain.OrganizationID.TenantID())
lockedDomain := &coredata.CustomDomain{} lockedDomain := &coredata.CustomDomain{}

View File

@@ -12,7 +12,7 @@
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE. // PERFORMANCE OF THIS SOFTWARE.
package cert package certmanager
import ( import (
"context" "context"
@@ -30,37 +30,27 @@ type (
Selector struct { Selector struct {
pg *pg.Client pg *pg.Client
cache sync.Map cache sync.Map
defaultDomain string
encryptionKey cipher.EncryptionKey encryptionKey cipher.EncryptionKey
defaultCert *tls.Certificate
defaultCertMutex sync.RWMutex
} }
) )
func NewSelector( func NewSelector(
pg *pg.Client, pg *pg.Client,
defaultDomain string,
encryptionKey cipher.EncryptionKey, encryptionKey cipher.EncryptionKey,
) *Selector { ) *Selector {
return &Selector{ return &Selector{
pg: pg, pg: pg,
defaultDomain: defaultDomain,
encryptionKey: encryptionKey, 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) { func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
domain := hello.ServerName domain := hello.ServerName
// Empty domain, use default // Empty domain, return error
if domain == "" { if domain == "" {
return s.getDefaultCertificate() return nil, fmt.Errorf("no SNI provided")
} }
if cached, ok := s.cache.Load(domain); ok { if cached, ok := s.cache.Load(domain); ok {
@@ -71,7 +61,7 @@ func (s *Selector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate,
cert, err := s.loadFromDatabase(domain) cert, err := s.loadFromDatabase(domain)
if err != nil { if err != nil {
return s.getDefaultCertificate() return nil, err
} }
s.cache.Store(domain, cert) s.cache.Store(domain, cert)
@@ -163,17 +153,6 @@ func (s *Selector) rebuildCacheEntry(ctx context.Context, conn pg.Conn, domain s
return nil 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() { func (s *Selector) ClearCache() {
s.cache.Range( s.cache.Range(