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..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// 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//) 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 }