initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user