diff --git a/pkg/cert/utils.go b/pkg/cert/utils.go deleted file mode 100644 index c87b091bd..000000000 --- a/pkg/cert/utils.go +++ /dev/null @@ -1,24 +0,0 @@ -// 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 diff --git a/pkg/cert/acme.go b/pkg/certmanager/acme.go similarity index 89% rename from pkg/cert/acme.go rename to pkg/certmanager/acme.go index 3d0731f8b..bc15df2e5 100644 --- a/pkg/cert/acme.go +++ b/pkg/certmanager/acme.go @@ -12,19 +12,21 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package cert +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" - cryptopem "github.com/getprobo/probo/pkg/crypto/pem" + "github.com/getprobo/probo/pkg/crypto/pem" "github.com/getprobo/probo/pkg/version" "go.gearno.de/kit/httpclient" "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) if err != nil { return nil, fmt.Errorf("cannot generate account key: %w", err) } - httpClient := httpclient.DefaultPooledClient( - httpclient.WithLogger(logger), - // httpclient.WithTracerProvider(tp), - // httpclient.WithRegisterer(r), - ) + 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, @@ -183,8 +196,8 @@ func (s *ACMEService) CompleteHTTPChallenge( return nil, fmt.Errorf("cannot parse certificate: %w", err) } - certPEM := cryptopem.EncodeCertificate(der[0]) - keyPEM, err := cryptopem.EncodePrivateKey(certKey) + certPEM := pem.EncodeCertificate(der[0]) + keyPEM, err := pem.EncodePrivateKey(certKey) if err != nil { return nil, fmt.Errorf("cannot encode key: %w", err) } @@ -193,7 +206,7 @@ func (s *ACMEService) CompleteHTTPChallenge( if len(der) > 1 { chainDER = der[1:] } - chainPEM := cryptopem.EncodeCertificateChain(chainDER) + chainPEM := pem.EncodeCertificateChain(chainDER) return &Certificate{ CertPEM: certPEM, @@ -268,8 +281,8 @@ func (s *ACMEService) renewWithExistingAuth(ctx context.Context, domain string) return nil, fmt.Errorf("cannot parse certificate: %w", err) } - certPEM := cryptopem.EncodeCertificate(der[0]) - keyPEM, err := cryptopem.EncodePrivateKey(certKey) + certPEM := pem.EncodeCertificate(der[0]) + keyPEM, err := pem.EncodePrivateKey(certKey) if err != nil { 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 { chainDER = der[1:] } - chainPEM := cryptopem.EncodeCertificateChain(chainDER) + chainPEM := pem.EncodeCertificateChain(chainDER) return &Certificate{ CertPEM: certPEM, diff --git a/pkg/certmanager/acme_challenge_handler.go b/pkg/certmanager/acme_challenge_handler.go new file mode 100644 index 000000000..afdcfd33e --- /dev/null +++ b/pkg/certmanager/acme_challenge_handler.go @@ -0,0 +1,100 @@ +// 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 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 +} diff --git a/pkg/cert/cache_warmer.go b/pkg/certmanager/cache_store.go similarity index 90% rename from pkg/cert/cache_warmer.go rename to pkg/certmanager/cache_store.go index 92faf79f4..1dfa0d1a0 100644 --- a/pkg/cert/cache_warmer.go +++ b/pkg/certmanager/cache_store.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package cert +package certmanager import ( "context" @@ -26,26 +26,26 @@ import ( ) type ( - CacheWarmer struct { + CacheStore struct { pg *pg.Client encryptionKey cipher.EncryptionKey logger *log.Logger } ) -func NewCacheWarmer( +func NewCacheStore( pg *pg.Client, encryptionKey cipher.EncryptionKey, logger *log.Logger, -) *CacheWarmer { - return &CacheWarmer{ +) *CacheStore { + return &CacheStore{ pg: pg, 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") startTime := time.Now() @@ -93,7 +93,7 @@ func (w *CacheWarmer) WarmCache(ctx context.Context) error { 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 scope := coredata.NewScope(domain.OrganizationID.TenantID()) 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 } -func (w *CacheWarmer) RefreshCache(ctx context.Context) error { +func (w *CacheStore) RefreshCache(ctx context.Context) error { w.logger.InfoCtx(ctx, "refreshing certificate cache") 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( ctx, func(conn pg.Conn) error { diff --git a/pkg/cert/certificate_provisioner.go b/pkg/certmanager/provisioner.go similarity index 92% rename from pkg/cert/certificate_provisioner.go rename to pkg/certmanager/provisioner.go index 4f1c46e69..5a24d4ecd 100644 --- a/pkg/cert/certificate_provisioner.go +++ b/pkg/certmanager/provisioner.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package cert +package certmanager import ( "context" @@ -26,7 +26,7 @@ import ( ) type ( - CertificateProvisioner struct { + Provisioner struct { pg *pg.Client acmeService *ACMEService encryptionKey cipher.EncryptionKey @@ -35,23 +35,23 @@ type ( } ) -func NewCertificateProvisioner( +func NewProvisioner( pg *pg.Client, acmeService *ACMEService, encryptionKey cipher.EncryptionKey, interval time.Duration, logger *log.Logger, -) *CertificateProvisioner { - return &CertificateProvisioner{ +) *Provisioner { + return &Provisioner{ pg: pg, acmeService: acmeService, encryptionKey: encryptionKey, 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)) 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 { var domains coredata.CustomDomains 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, conn pg.Conn, domain *coredata.CustomDomain, diff --git a/pkg/cert/certificate_renewer.go b/pkg/certmanager/renewer.go similarity index 90% rename from pkg/cert/certificate_renewer.go rename to pkg/certmanager/renewer.go index 535352ba9..5497cb0ad 100644 --- a/pkg/cert/certificate_renewer.go +++ b/pkg/certmanager/renewer.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package cert +package certmanager import ( "context" @@ -28,7 +28,7 @@ import ( ) type ( - CertificateRenewer struct { + Renewer struct { pg *pg.Client acmeService *ACMEService encryptionKey cipher.EncryptionKey @@ -37,24 +37,24 @@ type ( } ) -func NewCertificateRenewer( +func NewRenewer( pg *pg.Client, acmeService *ACMEService, encryptionKey cipher.EncryptionKey, interval time.Duration, logger *log.Logger, -) *CertificateRenewer { - return &CertificateRenewer{ +) *Renewer { + return &Renewer{ pg: pg, acmeService: acmeService, encryptionKey: encryptionKey, interval: interval, - logger: logger.Named("cert.certificate-renewer"), + logger: logger.Named("certmanager.renewer"), } } -func (r *CertificateRenewer) Run(ctx context.Context) error { - r.logger.InfoCtx(ctx, "certificate certificate-renewer starting") +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)) @@ -63,7 +63,7 @@ func (r *CertificateRenewer) Run(ctx context.Context) error { for { select { case <-ctx.Done(): - r.logger.InfoCtx(ctx, "certificate certificate-renewer shutting down") + r.logger.InfoCtx(ctx, "certificate renewer shutting down") return ctx.Err() case <-time.After(r.interval): 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( ctx, func(conn pg.Conn) error { @@ -84,7 +84,7 @@ func (r *CertificateRenewer) checkAndRenew(ctx context.Context) error { } else if cacheCount == 0 { 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 { r.logger.ErrorCtx(ctx, "cannot rebuild certificate cache", log.Error(err)) } 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()) lockedDomain := &coredata.CustomDomain{} diff --git a/pkg/cert/selector.go b/pkg/certmanager/selector.go similarity index 85% rename from pkg/cert/selector.go rename to pkg/certmanager/selector.go index 519d47259..51ed4f182 100644 --- a/pkg/cert/selector.go +++ b/pkg/certmanager/selector.go @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -package cert +package certmanager import ( "context" @@ -30,37 +30,27 @@ 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 + // Empty domain, return error if domain == "" { - return s.getDefaultCertificate() + return nil, fmt.Errorf("no SNI provided") } 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) if err != nil { - return s.getDefaultCertificate() + return nil, err } s.cache.Store(domain, cert) @@ -163,17 +153,6 @@ func (s *Selector) rebuildCacheEntry(ctx context.Context, conn pg.Conn, domain s 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(