// Package certificates manages TLS certificates: Let's Encrypt issuance // and renewal via lego (a proper ACME client library — we never speak the // ACME protocol ourselves), plus custom uploaded certificates. It serves // SNI lookups for the HTTPS listener from an in-memory cache and persists // metadata to SQLite via the database package. Private key material only // ever touches disk (0600) and is never logged. package certificates import ( "context" "crypto/ecdsa" "crypto/tls" "crypto/x509" "database/sql" "encoding/pem" "errors" "fmt" "log/slog" "net/http" "os" "strings" "sync" "time" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" "github.com/go-acme/lego/v4/challenge" "github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/providers/dns/cloudflare" "github.com/go-acme/lego/v4/registration" "github.com/owen/vibeproxy/internal/database" "github.com/owen/vibeproxy/internal/models" ) const ( letsEncryptProdURL = "https://acme-v02.api.letsencrypt.org/directory" letsEncryptStagingURL = "https://acme-staging-v02.api.letsencrypt.org/directory" ) type Manager struct { db *sql.DB storage *storage log *slog.Logger httpProvider *httpChallengeProvider mu sync.RWMutex byDom map[string]*tls.Certificate // lowercase domain -> cert onChange func() } func NewManager(db *sql.DB, storagePath string, log *slog.Logger) (*Manager, error) { st, err := newStorage(storagePath) if err != nil { return nil, err } return &Manager{ db: db, storage: st, log: log, httpProvider: newHTTPChallengeProvider(), byDom: map[string]*tls.Certificate{}, }, nil } // ServeChallenge handles ACME HTTP-01 requests on the plain HTTP listener. // Returns true if the request was handled (was an ACME challenge path). func (m *Manager) ServeChallenge(w http.ResponseWriter, r *http.Request) bool { return m.httpProvider.ServeHTTP(w, r) } func (m *Manager) SetOnChange(f func()) { m.onChange = f } // GetCertificateForDomain implements SNI lookup for tls.Config.GetCertificate. func (m *Manager) GetCertificateForDomain(domain string) (*tls.Certificate, error) { domain = strings.ToLower(domain) m.mu.RLock() defer m.mu.RUnlock() if c, ok := m.byDom[domain]; ok { return c, nil } if idx := strings.IndexByte(domain, '.'); idx >= 0 { wildcard := "*" + domain[idx:] if c, ok := m.byDom[wildcard]; ok { return c, nil } } return nil, fmt.Errorf("no certificate for domain %q", domain) } // LoadAll reads every stored certificate row, loads its key material from // disk, and rebuilds the SNI cache. Call at startup and after any // issuance/upload/deletion. func (m *Manager) LoadAll() error { certs, err := database.ListCertificates(m.db) if err != nil { return err } newMap := map[string]*tls.Certificate{} for _, c := range certs { if c.CertPath == "" || c.KeyPath == "" { continue } tlsCert, err := loadTLSCertificate(c.CertPath, c.KeyPath) if err != nil { m.log.Warn("failed to load certificate", "name", c.Name, "err", err) continue } for _, d := range c.Domains { newMap[strings.ToLower(d)] = tlsCert } } m.mu.Lock() m.byDom = newMap m.mu.Unlock() if m.onChange != nil { m.onChange() } return nil } // UploadCustom validates and stores a custom certificate/key pair. func (m *Manager) UploadCustom(name string, domains []string, certPEM, keyPEM []byte) (*models.Certificate, error) { leaf, err := ValidateKeyPair(certPEM, keyPEM) if err != nil { return nil, err } certPath, keyPath, err := m.storage.SavePair(name, certPEM, keyPEM) if err != nil { return nil, err } notBefore, notAfter := leaf.NotBefore, leaf.NotAfter rec := &models.Certificate{ Name: name, Type: models.CertTypeCustom, Domains: domains, CertPath: certPath, KeyPath: keyPath, NotBefore: ¬Before, NotAfter: ¬After, AutoRenew: false, } if _, err := database.SaveCertificate(m.db, rec); err != nil { return nil, err } if err := m.LoadAll(); err != nil { return nil, err } return rec, nil } // IssueLetsEncryptOptions configures a new ACME issuance. type IssueLetsEncryptOptions struct { Name string Domains []string Email string Staging bool ChallengeType models.ChallengeType DNSProvider string // e.g. "cloudflare"; required for dns-01 } // IssueLetsEncrypt requests a new certificate from Let's Encrypt and // persists it. HTTP-01 challenges are served via ServeChallenge on our own // HTTP listener (port 80 must be reachable from the internet); DNS-01 uses // the configured DNS provider and also supports wildcard domains. func (m *Manager) IssueLetsEncrypt(ctx context.Context, opts IssueLetsEncryptOptions) (*models.Certificate, error) { if opts.Email == "" { return nil, errors.New("an ACME contact email is required (configure it under Settings)") } accountKey, err := m.loadOrCreateAccountKey() if err != nil { return nil, err } user := newACMEUser(opts.Email, accountKey) cfg := lego.NewConfig(user) if opts.Staging { cfg.CADirURL = letsEncryptStagingURL } else { cfg.CADirURL = letsEncryptProdURL } cfg.Certificate.KeyType = certcrypto.RSA2048 client, err := lego.NewClient(cfg) if err != nil { return nil, err } if opts.ChallengeType == models.ChallengeDNS01 { provider, err := m.dnsProvider(opts.DNSProvider) if err != nil { return nil, err } if err := client.Challenge.SetDNS01Provider(provider); err != nil { return nil, err } } else { if err := client.Challenge.SetHTTP01Provider(m.httpProvider); err != nil { return nil, err } } reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) if err != nil { return nil, fmt.Errorf("acme registration: %w", err) } user.Registration = reg res, err := client.Certificate.Obtain(certificate.ObtainRequest{ Domains: opts.Domains, Bundle: true, }) if err != nil { return nil, fmt.Errorf("acme obtain: %w", err) } certPath, keyPath, err := m.storage.SavePair(opts.Name, res.Certificate, res.PrivateKey) if err != nil { return nil, err } var notBefore, notAfter *time.Time if block, _ := pem.Decode(res.Certificate); block != nil { if leaf, err := x509.ParseCertificate(block.Bytes); err == nil { nb, na := leaf.NotBefore, leaf.NotAfter notBefore, notAfter = &nb, &na } } now := time.Now() rec := &models.Certificate{ Name: opts.Name, Type: models.CertTypeLetsEncrypt, Domains: opts.Domains, ChallengeType: opts.ChallengeType, DNSProvider: opts.DNSProvider, CertPath: certPath, KeyPath: keyPath, NotBefore: notBefore, NotAfter: notAfter, LastRenewedAt: &now, AutoRenew: true, } if _, err := database.SaveCertificate(m.db, rec); err != nil { return nil, err } if err := m.LoadAll(); err != nil { return nil, err } return rec, nil } func (m *Manager) dnsProvider(name string) (challenge.Provider, error) { switch name { case "cloudflare": return cloudflare.NewDNSProvider() default: return nil, fmt.Errorf("unsupported DNS provider %q (supported: cloudflare; set its env vars, or use HTTP-01 instead)", name) } } // RenewDue re-issues any Let's Encrypt certificate whose expiry falls // inside the configured renewal window. Returns the number renewed. func (m *Manager) RenewDue(ctx context.Context, renewalWindowDays int, email string, staging bool) (int, error) { certs, err := database.ListCertificates(m.db) if err != nil { return 0, err } renewed := 0 cutoff := time.Now().Add(time.Duration(renewalWindowDays) * 24 * time.Hour) for _, c := range certs { if c.Type != models.CertTypeLetsEncrypt || !c.AutoRenew { continue } if c.NotAfter == nil || c.NotAfter.After(cutoff) { continue } if _, err := m.IssueLetsEncrypt(ctx, IssueLetsEncryptOptions{ Name: c.Name, Domains: c.Domains, Email: email, Staging: staging, ChallengeType: c.ChallengeType, DNSProvider: c.DNSProvider, }); err != nil { c.LastError = err.Error() database.SaveCertificate(m.db, &c) m.log.Error("certificate renewal failed", "name", c.Name, "err", err) continue } renewed++ } return renewed, nil } // StartRenewalLoop runs RenewDue once a day until ctx is cancelled. func (m *Manager) StartRenewalLoop(ctx context.Context, renewalWindowDays int, email string, staging bool) { ticker := time.NewTicker(24 * time.Hour) go func() { defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if n, err := m.RenewDue(ctx, renewalWindowDays, email, staging); err != nil { m.log.Error("renewal loop error", "err", err) } else if n > 0 { m.log.Info("renewed certificates", "count", n) } } } }() } func (m *Manager) loadOrCreateAccountKey() (*ecdsa.PrivateKey, error) { path := m.storage.accountKeyPath() if _, err := os.Stat(path); err == nil { return readECDSAKeyPEM(path) } key, err := newACMEAccountKey() if err != nil { return nil, err } if err := writeECDSAKeyPEM(path, key); err != nil { return nil, err } return key, nil } func DeleteCertificate(db *sql.DB, id int64) error { return database.DeleteCertificate(db, id) }