initial commit
Format / gofmt (push) Failing after 27s
CI / Build (push) Successful in 51s
CI / Go Tests (push) Failing after 21s

This commit is contained in:
2026-06-06 07:54:44 -05:00
commit 1cc94f2c99
68 changed files with 14615 additions and 0 deletions
+365
View File
@@ -0,0 +1,365 @@
package certificate
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"log/slog"
"math/big"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/http/webroot"
"github.com/go-acme/lego/v4/registration"
"helix-proxy/internal/config"
"helix-proxy/internal/store"
)
// Manager handles Let's Encrypt (and future other) certificate issuance and renewal using lego.
// It is the pure-Go certificate issuance manager (using lego for LE, no external certbot).
type Manager struct {
st store.Store
}
// NewManager returns a cert manager bound to the store (for read/update of Certificate records).
func NewManager(st store.Store) *Manager {
return &Manager{st: st}
}
// account represents a lego user/account. We generate ephemeral keys for v1 (new LE account per issue).
// For production persistence of account key, we could save to data/certs/lego-account.<email>.key .
type account struct {
Email string
Registration *registration.Resource
key crypto.PrivateKey
}
func (a *account) GetEmail() string {
return a.Email
}
func (a *account) GetRegistration() *registration.Resource {
return a.Registration
}
func (a *account) GetPrivateKey() crypto.PrivateKey {
return a.key
}
// Issue obtains (or renews) a certificate for the given store.Certificate record (must have provider=="letsencrypt").
// It writes PEMs into the cert's Meta["cert"] and Meta["key"] (for engine loadCerts compatibility with custom certs),
// updates expires_on, and persists via store.SetCertificate.
// It also optionally writes PEM files under data/certs/<id>/ for inspection/backup.
// email is the contact for LE registration (required for LE); falls back to "admin@example.com" if empty.
// Respects meta["staging"]=true or env LEGO_STAGING=1 to use staging (recommended for testing to avoid rate limits).
// Respects meta["key_type"] = "rsa" | "ecdsa" (default rsa2048).
// If PROXY_MODE=development, uses self-signed test cert for *all* LE certs (no domain tricks).
func (m *Manager) Issue(sc store.Certificate, email string) error {
if sc.Provider != "letsencrypt" {
return fmt.Errorf("not a letsencrypt certificate (provider=%s)", sc.Provider)
}
if len(sc.DomainNames) == 0 {
return fmt.Errorf("no domain_names for certificate %d", sc.ID)
}
if email == "" {
email = "admin@example.com"
}
// In development mode (PROXY_MODE=development), *all* letsencrypt certs use the
// self-signed test path (no real LE). This is for dev/testing only.
// In production (default), real LE http-01 is always attempted.
if config.IsDevelopment() {
slog.Info("using self-signed test cert (LE simulation; all certs in dev mode)", "domains", sc.DomainNames)
return m.issueTestCert(sc)
}
staging := false
if sc.Meta != nil {
if v, ok := sc.Meta["staging"]; ok {
switch t := v.(type) {
case bool:
staging = t
case string:
staging = strings.ToLower(t) == "true" || t == "1"
}
}
}
if os.Getenv("LEGO_STAGING") != "" || os.Getenv("LE_STAGING") != "" {
staging = true
}
keyType := certcrypto.RSA2048
if sc.Meta != nil {
if kt, ok := sc.Meta["key_type"].(string); ok {
switch strings.ToLower(kt) {
case "ecdsa", "ec256", "p256":
keyType = certcrypto.EC256
case "rsa4096":
keyType = certcrypto.RSA4096
}
}
}
// Create lego account (ephemeral key; see note above)
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate account key: %w", err)
}
acc := &account{
Email: email,
key: privateKey,
}
cfg := lego.NewConfig(acc)
if staging {
cfg.CADirURL = lego.LEDirectoryStaging
slog.Info("using Let's Encrypt staging for certificate issue", "certID", sc.ID, "domains", sc.DomainNames)
} else {
cfg.CADirURL = lego.LEDirectoryProduction
}
cfg.Certificate.KeyType = keyType
client, err := lego.NewClient(cfg)
if err != nil {
return fmt.Errorf("lego client: %w", err)
}
// http-01 via our served challenge dir (lego writes token files, our proxy serves them)
webrootPath := config.Resolve("letsencrypt-acme-challenge")
// ensure layout exists
_ = os.MkdirAll(filepath.Join(webrootPath, ".well-known", "acme-challenge"), 0o755)
provider, err := webroot.NewHTTPProvider(webrootPath)
if err != nil {
return fmt.Errorf("webroot provider: %w", err)
}
if err := client.Challenge.SetHTTP01Provider(provider); err != nil {
return fmt.Errorf("set http01 provider: %w", err)
}
// Register account (or resolve). For ephemeral this always creates a fresh LE account.
reg, err := client.Registration.Register(registration.RegisterOptions{
TermsOfServiceAgreed: true,
})
if err != nil {
// If already registered for key (rare here), try resolve; otherwise fail
if reg2, err2 := client.Registration.ResolveAccountByKey(); err2 == nil {
reg = reg2
} else {
return fmt.Errorf("le register: %w", err)
}
}
acc.Registration = reg
slog.Info("requesting LE certificate", "certID", sc.ID, "domains", sc.DomainNames, "staging", staging, "email", email)
request := certificate.ObtainRequest{
Domains: sc.DomainNames,
Bundle: true,
}
certs, err := client.Certificate.Obtain(request)
if err != nil {
return fmt.Errorf("obtain cert: %w", err)
}
// Populate PEMs in meta (used by engine for tls.Certificate and by custom cert path)
// Use compat keys "certificate"/"certificate_key" + "cert"/"key" (for data format compatibility)
if sc.Meta == nil {
sc.Meta = map[string]any{}
}
sc.Meta["certificate"] = string(certs.Certificate)
sc.Meta["certificate_key"] = string(certs.PrivateKey)
sc.Meta["cert"] = string(certs.Certificate)
sc.Meta["key"] = string(certs.PrivateKey)
// keep original meta bits like dns_challenge etc.
sc.Meta["letsencrypt"] = map[string]any{
"account": email,
"staging": staging,
"issued_at": time.Now().UTC().Format(time.RFC3339),
}
// Compute real expiry from the returned leaf cert (more reliable than LE response)
expires := ""
if block, _ := pem.Decode(certs.Certificate); block != nil {
if xc, err := x509.ParseCertificate(block.Bytes); err == nil {
expires = xc.NotAfter.UTC().Format("2006-01-02 15:04:05")
sc.Meta["cert_info"] = map[string]any{
"not_after": xc.NotAfter.UTC().Format(time.RFC3339),
"issuer": xc.Issuer.String(),
"subject": xc.Subject.String(),
}
}
}
if expires == "" {
expires = time.Now().Add(90 * 24 * time.Hour).Format("2006-01-02 15:04:05") // fallback ~90d
}
sc.ExpiresOn = expires
// Persist files too (under data/certs/<id>/) for serving live certs
certDir := filepath.Join(config.Resolve("certs"), fmt.Sprintf("%d", sc.ID))
if err := os.MkdirAll(certDir, 0o755); err == nil {
_ = os.WriteFile(filepath.Join(certDir, "fullchain.pem"), certs.Certificate, 0o600)
_ = os.WriteFile(filepath.Join(certDir, "privkey.pem"), certs.PrivateKey, 0o600)
}
// Save back to store (updates meta + expires)
if err := m.st.SetCertificate(sc); err != nil {
return fmt.Errorf("store set cert after issue: %w", err)
}
slog.Info("LE certificate obtained and stored", "certID", sc.ID, "domains", sc.DomainNames, "expires", sc.ExpiresOn)
return nil
}
// GetEmailForCert tries to find a reasonable contact email for LE from the cert meta or first admin user.
func (m *Manager) GetEmailForCert(sc store.Certificate) string {
if sc.Meta != nil {
if e, ok := sc.Meta["email"].(string); ok && e != "" {
return e
}
if e, ok := sc.Meta["letsencrypt_email"].(string); ok && e != "" {
return e
}
}
// fallback to global setting or seeded admin
if settings := m.st.GetSettings(); settings != nil {
if e, ok := settings["letsencrypt_email"].(string); ok && e != "" {
return e
}
}
users := m.st.GetUsers()
for _, u := range users {
if u.Email != "" {
return u.Email
}
}
return "admin@example.com"
}
// ShouldRenew reports whether a LE cert is within the renewal window (30 days before expiry).
func (m *Manager) ShouldRenew(sc store.Certificate) bool {
if sc.Provider != "letsencrypt" {
return false
}
if sc.ExpiresOn == "" {
return true
}
t, err := time.Parse("2006-01-02 15:04:05", sc.ExpiresOn)
if err != nil {
// try RFC3339 etc
if t2, err2 := time.Parse(time.RFC3339, sc.ExpiresOn); err2 == nil {
t = t2
} else {
return true
}
}
renewBefore := 30 * 24 * time.Hour
return time.Until(t) < renewBefore
}
// ProcessRenewals finds LE certs due for renewal and re-issues them (sequentially).
// Called by timer and on demand.
func (m *Manager) ProcessRenewals() {
certs := m.st.GetCertificates()
for _, c := range certs {
if c.Provider == "letsencrypt" && m.ShouldRenew(c) {
slog.Info("renewing expiring LE cert", "id", c.ID, "domains", c.DomainNames, "expires", c.ExpiresOn)
email := m.GetEmailForCert(c)
if err := m.Issue(c, email); err != nil {
slog.Error("renewal failed (will retry later)", "id", c.ID, "err", err)
continue
}
}
}
}
// --- dev-mode test helpers (all letsencrypt certs become self-signed test certs when PROXY_MODE=development) ---
func (m *Manager) issueTestCert(sc store.Certificate) error {
certPEM, keyPEM, err := generateSelfSignedTestCert(sc.DomainNames)
if err != nil {
return err
}
if sc.Meta == nil {
sc.Meta = map[string]any{}
}
sc.Meta["certificate"] = string(certPEM)
sc.Meta["certificate_key"] = string(keyPEM)
sc.Meta["cert"] = string(certPEM)
sc.Meta["key"] = string(keyPEM)
sc.Meta["test_cert"] = true // marker that this was dev sim, not real LE
sc.Meta["letsencrypt"] = map[string]any{
"account": "test@localhost",
"staging": true,
"issued_at": time.Now().UTC().Format(time.RFC3339),
"note": "self-signed simulation (only in PROXY_MODE=development)",
}
// parse for expires
expires := ""
if block, _ := pem.Decode(certPEM); block != nil {
if xc, err := x509.ParseCertificate(block.Bytes); err == nil {
expires = xc.NotAfter.UTC().Format("2006-01-02 15:04:05")
sc.Meta["cert_info"] = map[string]any{
"not_after": xc.NotAfter.UTC().Format(time.RFC3339),
"issuer": xc.Issuer.String(),
"subject": xc.Subject.String(),
}
}
}
if expires == "" {
expires = time.Now().Add(90 * 24 * time.Hour).Format("2006-01-02 15:04:05")
}
sc.ExpiresOn = expires
// write files
certDir := filepath.Join(config.Resolve("certs"), fmt.Sprintf("%d", sc.ID))
_ = os.MkdirAll(certDir, 0o755)
_ = os.WriteFile(filepath.Join(certDir, "fullchain.pem"), certPEM, 0o600)
_ = os.WriteFile(filepath.Join(certDir, "privkey.pem"), keyPEM, 0o600)
if err := m.st.SetCertificate(sc); err != nil {
return err
}
slog.Info("test self-signed cert stored (LE simulation, dev-only)", "certID", sc.ID, "domains", sc.DomainNames)
return nil
}
func generateSelfSignedTestCert(domains []string) (certPEM, keyPEM []byte, err error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
template := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: domains[0], Organization: []string{"Helix Proxy test"}},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(90 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
DNSNames: domains,
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, nil, err
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER := x509.MarshalPKCS1PrivateKey(priv)
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}