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
}
+230
View File
@@ -0,0 +1,230 @@
package certificate
import (
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"strings"
"testing"
"time"
"helix-proxy/internal/config"
"helix-proxy/internal/store"
)
func TestGenerateSelfSignedTestCert(t *testing.T) {
domains := []string{"foo.example.com", "bar.example.com"}
certPEM, keyPEM, err := generateSelfSignedTestCert(domains)
if err != nil {
t.Fatalf("generate: %v", err)
}
if !strings.Contains(string(certPEM), "BEGIN CERTIFICATE") {
t.Error("no cert pem marker")
}
if !strings.Contains(string(keyPEM), "BEGIN RSA PRIVATE KEY") {
t.Error("no key pem marker")
}
// parse and check
block, _ := pem.Decode(certPEM)
if block == nil {
t.Fatal("decode failed")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse: %v", err)
}
if cert.NotAfter.Sub(cert.NotBefore) < 89*24*time.Hour {
t.Error("validity too short")
}
found := false
for _, d := range cert.DNSNames {
if d == "foo.example.com" {
found = true
}
}
if !found {
t.Error("dns names not in cert")
}
}
func TestIssueTestCertPath(t *testing.T) {
// use temp data dir, reset config
config.ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
// ensure dirs so writes don't fail
_ = config.EnsureDataDirs()
// minimal store: use New which is bbolt in the tmp (via reset+env)
st, err := store.New()
if err != nil {
t.Fatalf("new store: %v", err)
}
if c, ok := st.(interface{ Close() error }); ok {
defer c.Close()
}
cm := NewManager(st)
// create a cert record (simulating what API would do)
cert := store.Certificate{
Provider: "letsencrypt",
NiceName: "test local",
DomainNames: []string{"mytest.example.com"}, // any domain; in dev mode all LE become test certs
Meta: map[string]any{},
}
created, err := st.CreateCertificate(cert)
if err != nil {
t.Fatalf("create cert: %v", err)
}
// dev mode => all letsencrypt certs are test/self-signed
t.Setenv("PROXY_MODE", "development")
email := cm.GetEmailForCert(created)
if err := cm.Issue(created, email); err != nil {
t.Fatalf("Issue test cert: %v", err)
}
// reload from store
got, ok := st.GetCertificate(created.ID)
if !ok {
t.Fatal("cert not found after issue")
}
if got.ExpiresOn == "" {
t.Error("no expires set")
}
if v, _ := got.Meta["test_cert"].(bool); !v {
t.Error("test_cert marker not set")
}
if _, ok := got.Meta["certificate"]; !ok {
t.Error("certificate pem not in meta")
}
if _, ok := got.Meta["key"]; !ok {
t.Error("key not in meta")
}
// file written?
if _, err := os.Stat(config.Resolve("certs")); err != nil {
t.Error("certs dir missing")
}
// the per-id dir
perID := fmt.Sprintf("%s/%d", config.Resolve("certs"), got.ID)
if _, err := os.Stat(perID); err != nil {
t.Logf("per-id cert dir %s not present (may be ok): %v", perID, err)
}
// exercise non-local LE path meta parsing (staging/key_type from meta) in *production* mode
// (real LE path; will fail on obtain due to no DNS/reach, but pre-LE branches + meta covered)
t.Setenv("PROXY_MODE", "")
c2 := store.Certificate{
Provider: "letsencrypt",
DomainNames: []string{"nonlocal.example.com"},
Meta: map[string]any{"staging": "true", "key_type": "rsa4096"},
}
created2, _ := st.CreateCertificate(c2)
email2 := cm.GetEmailForCert(created2)
if err2 := cm.Issue(created2, email2); err2 == nil || strings.Contains(err2.Error(), "not a letsencrypt") {
t.Logf("non-local LE Issue err (expected without public reach/DNS; meta parse covered): %v", err2)
}
}
func TestIssueRejectsNonLE(t *testing.T) {
config.ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
_ = config.EnsureDataDirs()
st, err := store.New()
if err != nil {
t.Fatalf("store: %v", err)
}
if c, ok := st.(interface{ Close() error }); ok {
defer c.Close()
}
cm := NewManager(st)
c := store.Certificate{Provider: "other", DomainNames: []string{"ex.com"}}
created, _ := st.CreateCertificate(c)
err = cm.Issue(created, "e@e.com")
if err == nil || !strings.Contains(err.Error(), "not a letsencrypt") {
t.Errorf("expected reject non-letsencrypt: got %v", err)
}
}
func TestShouldRenew(t *testing.T) {
config.ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
_ = config.EnsureDataDirs()
st, err := store.New()
if err != nil {
t.Fatalf("store: %v", err)
}
if c, ok := st.(interface{ Close() error }); ok {
defer c.Close()
}
cm := NewManager(st)
now := time.Now().UTC()
tests := []struct {
name string
exp string
want bool
}{
{"empty", "", true},
{"past", now.Add(-40 * 24 * time.Hour).Format("2006-01-02 15:04:05"), true},
{"near 29d", now.Add(29 * 24 * time.Hour).Format("2006-01-02 15:04:05"), true},
{"far 31d", now.Add(31 * 24 * time.Hour).Format("2006-01-02 15:04:05"), false},
{"rfc3339 near", now.Add(10 * 24 * time.Hour).Format(time.RFC3339), true},
{"bad format", "not-a-date", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := store.Certificate{Provider: "letsencrypt", ExpiresOn: tt.exp}
if got := cm.ShouldRenew(c); got != tt.want {
t.Errorf("ShouldRenew(%s) = %v want %v", tt.exp, got, tt.want)
}
})
}
}
func TestProcessRenewals(t *testing.T) {
config.ResetForTest()
tmp := t.TempDir()
t.Setenv("DATA_DIR", tmp)
_ = config.EnsureDataDirs()
st, err := store.New()
if err != nil {
t.Fatalf("store: %v", err)
}
if c, ok := st.(interface{ Close() error }); ok {
defer c.Close()
}
cm := NewManager(st)
// dev mode: *all* letsencrypt certs (any domain) use test/self-signed path in Issue/renew
t.Setenv("PROXY_MODE", "development")
// LE due soon
dueExp := time.Now().Add(5 * 24 * time.Hour).Format("2006-01-02 15:04:05")
leDue := store.Certificate{Provider: "letsencrypt", DomainNames: []string{"due.example.com"}, ExpiresOn: dueExp}
createdDue, _ := st.CreateCertificate(leDue)
// LE far future (no renew)
farExp := time.Now().Add(40 * 24 * time.Hour).Format("2006-01-02 15:04:05")
leFar := store.Certificate{Provider: "letsencrypt", DomainNames: []string{"far.example.com"}, ExpiresOn: farExp}
createdFar, _ := st.CreateCertificate(leFar)
// non-LE (ignored even if "due")
nonLE := store.Certificate{Provider: "other", DomainNames: []string{"x.com"}, ExpiresOn: time.Now().Add(-1 * time.Hour).Format("2006-01-02 15:04:05")}
st.CreateCertificate(nonLE)
cm.ProcessRenewals()
gDue, _ := st.GetCertificate(createdDue.ID)
if tDue, _ := time.Parse("2006-01-02 15:04:05", gDue.ExpiresOn); time.Until(tDue) < 80*24*time.Hour {
t.Error("due LE cert was not renewed (expires not extended)")
}
gFar, _ := st.GetCertificate(createdFar.ID)
if gFar.ExpiresOn != farExp {
t.Error("far LE should not have been touched")
}
}