Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go, with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS framework), and a REST API under /api/v1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
336 lines
9.1 KiB
Go
336 lines
9.1 KiB
Go
// 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)
|
|
}
|