Files
steward/internal/certs/manager.go
T
abrown38andClaude Opus 5.5 7b3f0f764c The web UI's certificate from Let's Encrypt, through Jarl
tls.acme names the certificate and jarl: the Jarl server and a token that
may only write the challenge record. Steward gets the certificate with
DNS-01 (lego), writing the record through Jarl's API into every view that
has the zone, renews it with a third of its lifetime left and swaps it
in without a restart. A check runs before every order: Jarl, the zone,
the token's permission, a test record, the zone's name servers, public
resolvers, clean-up; each step says what to fix. Staging by default.

With a certificate, each listener serves HTTPS and plain HTTP on one port
(redirecting plain HTTP, except from the server itself).

steward cert / cert check / cert renew, Server -> Certificate in the web
UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md
and docs/jarl.md (the Jarl user and the token's grant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:46:36 +01:00

485 lines
14 KiB
Go

// Package certs obtains and renews the web UI's certificate from an ACME CA
// (Let's Encrypt), with Jarl publishing the DNS-01 challenge
// (docs/certificates.md). Before every order a preflight check proves the
// challenge can be published and seen, so a broken setup never spends the
// CA's rate limits.
package certs
import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/challenge/dns01"
"github.com/go-acme/lego/v4/lego"
legolog "github.com/go-acme/lego/v4/log"
"github.com/go-acme/lego/v4/registration"
"steward/internal/api"
"steward/internal/config"
"steward/internal/jarl"
)
// Store keeps the account and certificate (the database's meta table).
type Store interface {
GetMeta(ctx context.Context, key string) (string, error)
SetMeta(ctx context.Context, key, value string) error
}
const (
metaAccount = "acme.account"
metaCert = "acme.certificate"
)
// Config configures the manager.
type Config struct {
ACME config.ACME
Version string
}
// Status is api.CertStatus.
type Status = api.CertStatus
// Manager keeps the web UI's certificate.
type Manager struct {
cfg Config
jarl *jarl.Client
store Store
log *slog.Logger
now func() time.Time
mu sync.Mutex
cert *tls.Certificate
leaf *x509.Certificate
issuedBy string
working bool
lastErr string
lastAttempt time.Time
nextAttempt time.Time
lastCheck *Check
failures int
force bool // renew now, even if not due (Kick)
lego sync.Mutex // one ACME order at a time
kick chan struct{}
}
// New makes a manager and loads the stored certificate.
func New(ctx context.Context, cfg Config, j *jarl.Client, st Store, log *slog.Logger) *Manager {
if log == nil {
log = slog.Default()
}
// lego logs each step of an order; send it to the journal with the rest.
legolog.Logger = slog.NewLogLogger(log.With("component", "acme").Handler(), slog.LevelInfo)
m := &Manager{cfg: cfg, jarl: j, store: st, log: log, now: time.Now, kick: make(chan struct{}, 1)}
m.load(ctx)
return m
}
type stored struct {
Domain string `json:"domain"`
Directory string `json:"directory"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
Obtained time.Time `json:"obtained"`
}
func (m *Manager) load(ctx context.Context) {
raw, err := m.store.GetMeta(ctx, metaCert)
if err != nil || raw == "" {
return
}
var s stored
if json.Unmarshal([]byte(raw), &s) != nil {
return
}
if err := m.use(s); err != nil {
m.log.Warn("the stored certificate can't be used; getting a new one", "err", err)
}
}
// use makes a stored certificate the one served.
func (m *Manager) use(s stored) error {
c, err := tls.X509KeyPair([]byte(s.CertPEM), []byte(s.KeyPEM))
if err != nil {
return err
}
leaf, err := x509.ParseCertificate(c.Certificate[0])
if err != nil {
return err
}
c.Leaf = leaf
m.mu.Lock()
defer m.mu.Unlock()
// A certificate for another name isn't served.
if !strings.EqualFold(s.Domain, m.cfg.ACME.Domain) {
m.cert, m.leaf = nil, nil
return fmt.Errorf("it is for %s, not %s", s.Domain, m.cfg.ACME.Domain)
}
m.cert, m.leaf, m.issuedBy = &c, leaf, s.Directory
return nil
}
// Certificate is the certificate to serve (nil when there is none yet).
func (m *Manager) Certificate() *tls.Certificate {
m.mu.Lock()
defer m.mu.Unlock()
if m.cert == nil || m.now().After(m.leaf.NotAfter) {
return nil
}
return m.cert
}
// Status describes the certificate.
func (m *Manager) Status() Status {
m.mu.Lock()
defer m.mu.Unlock()
st := Status{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), Staging: m.cfg.ACME.Staging(), State: "none",
IssuedBy: m.issuedBy, LastError: m.lastErr, Check: m.lastCheck}
if m.leaf != nil {
nb, na := m.leaf.NotBefore, m.leaf.NotAfter
ra := m.renewAt()
st.NotBefore, st.NotAfter, st.RenewAt, st.Issuer, st.State = &nb, &na, &ra, m.leaf.Issuer.CommonName, "ok"
}
if m.lastErr != "" {
st.State = "error"
}
if m.working {
st.State = "working"
}
if !m.lastAttempt.IsZero() {
t := m.lastAttempt
st.LastAttempt = &t
}
if !m.nextAttempt.IsZero() {
t := m.nextAttempt
st.NextAttempt = &t
}
return st
}
// renewAt is when the certificate is renewed: a third of its lifetime
// before it ends (30 days for Let's Encrypt's 90), m.mu held.
func (m *Manager) renewAt() time.Time {
life := m.leaf.NotAfter.Sub(m.leaf.NotBefore)
return m.leaf.NotAfter.Add(-life / 3).UTC().Truncate(time.Second)
}
// due reports whether a new certificate is needed: none yet, another
// directory configured, or renewal time.
func (m *Manager) due() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.force || m.leaf == nil || m.issuedBy != m.cfg.ACME.DirectoryURL() || !m.now().Before(m.renewAt())
}
// Run keeps the certificate: it gets one when needed, checks twice a day
// whether it is due, and retries failures with a growing wait.
func (m *Manager) Run(ctx context.Context) {
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-m.kick:
case <-timer.C:
}
wait := 12 * time.Hour
if m.due() {
if err := m.Renew(ctx); err != nil {
m.mu.Lock()
m.failures++
// 1h, 3h, 6h, then 12h.
waits := []time.Duration{time.Hour, 3 * time.Hour, 6 * time.Hour, 12 * time.Hour}
wait = waits[min(m.failures, len(waits))-1]
m.mu.Unlock()
m.log.Warn("getting the web UI's certificate failed; trying again later", "domain", m.cfg.ACME.Domain, "err", err, "retry_in", wait)
}
}
m.mu.Lock()
m.nextAttempt = m.now().Add(wait).UTC()
m.mu.Unlock()
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(wait)
}
}
// Kick asks Run to renew now, even if the certificate isn't due.
func (m *Manager) Kick() {
m.mu.Lock()
m.force, m.failures = true, 0
m.mu.Unlock()
select {
case m.kick <- struct{}{}:
default:
}
}
// ErrBusy is returned while another order runs.
var ErrBusy = errors.New("a certificate order is already running")
// Renew gets a new certificate now: the preflight check, then the order.
func (m *Manager) Renew(ctx context.Context) error {
if !m.lego.TryLock() {
return ErrBusy
}
defer m.lego.Unlock()
m.mu.Lock()
m.working, m.lastAttempt = true, m.now().UTC()
m.mu.Unlock()
err := m.renew(ctx)
m.mu.Lock()
m.working, m.force = false, false
if err != nil {
m.lastErr = err.Error()
} else {
m.lastErr, m.failures = "", 0
}
m.mu.Unlock()
return err
}
func (m *Manager) renew(ctx context.Context) error {
c := m.Preflight(ctx)
if !c.OK {
for _, s := range c.Steps {
if s.Status == "fail" {
return fmt.Errorf("the check before asking the CA failed at %s: %s", strings.ToLower(s.Name), s.Detail)
}
}
}
m.log.Info("the check passed; asking the CA for a certificate", "domain", m.cfg.ACME.Domain, "directory", m.cfg.ACME.DirectoryURL())
res, err := m.obtain(ctx)
if err != nil {
return err
}
s := stored{Domain: m.cfg.ACME.Domain, Directory: m.cfg.ACME.DirectoryURL(), CertPEM: string(res.Certificate), KeyPEM: string(res.PrivateKey), Obtained: m.now().UTC()}
if err := m.use(s); err != nil {
return fmt.Errorf("the CA's certificate can't be used: %w", err)
}
b, _ := json.Marshal(s)
if err := m.store.SetMeta(ctx, metaCert, string(b)); err != nil {
return err
}
m.mu.Lock()
na := m.leaf.NotAfter
m.mu.Unlock()
m.log.Info("got a new certificate for the web UI", "domain", s.Domain, "until", na.Format(time.RFC3339), "staging", m.cfg.ACME.Staging())
return nil
}
// ---- ACME
type account struct {
Directory string `json:"directory"`
Email string `json:"email,omitempty"`
URI string `json:"uri,omitempty"`
KeyPEM string `json:"key_pem"`
}
type legoUser struct {
email string
reg *registration.Resource
key crypto.PrivateKey
}
func (u *legoUser) GetEmail() string { return u.email }
func (u *legoUser) GetRegistration() *registration.Resource { return u.reg }
func (u *legoUser) GetPrivateKey() crypto.PrivateKey { return u.key }
func (m *Manager) httpClient() (*http.Client, error) {
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
if f := m.cfg.ACME.CABundle; f != "" {
pemData, err := os.ReadFile(f)
if err != nil {
return nil, fmt.Errorf("tls.acme.ca_bundle: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pemData) {
return nil, fmt.Errorf("tls.acme.ca_bundle: no certificates in %s", f)
}
tr.TLSClientConfig.RootCAs = pool
}
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
}
// client returns a lego client for the account, registering it if needed.
func (m *Manager) client(ctx context.Context) (*lego.Client, error) {
dir := m.cfg.ACME.DirectoryURL()
hc, err := m.httpClient()
if err != nil {
return nil, err
}
user := &legoUser{email: m.cfg.ACME.Email}
var a account
if raw, _ := m.store.GetMeta(ctx, metaAccount); raw != "" && json.Unmarshal([]byte(raw), &a) == nil && a.Directory == dir && a.KeyPEM != "" {
b, _ := pem.Decode([]byte(a.KeyPEM))
if b == nil {
return nil, errors.New("the stored ACME account key isn't PEM")
}
if user.key, err = x509.ParsePKCS8PrivateKey(b.Bytes); err != nil {
return nil, fmt.Errorf("the stored ACME account key: %w", err)
}
if a.URI != "" {
user.reg = &registration.Resource{URI: a.URI}
}
} else {
if user.key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader); err != nil {
return nil, err
}
}
cfg := lego.NewConfig(user)
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "steward/"+m.cfg.Version
cfg.Certificate.Timeout = 90 * time.Second
c, err := lego.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("the ACME server %s: %w", dir, err)
}
if user.reg != nil {
return c, nil
}
if !m.cfg.ACME.AgreeTOS {
tos := c.GetToSURL()
if tos == "" {
tos = "on the CA's website"
}
return nil, fmt.Errorf("Steward registers an account with %s only after you accept its terms of service (%s): set tls.acme.agree_tos: true in steward.yaml and restart stewardd", dir, tos)
}
reg, err := c.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
if err != nil {
return nil, fmt.Errorf("registering the ACME account: %w", err)
}
user.reg = reg
der, err := x509.MarshalPKCS8PrivateKey(user.key)
if err != nil {
return nil, err
}
b, _ := json.Marshal(account{Directory: dir, Email: user.email, URI: reg.URI,
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))})
if err := m.store.SetMeta(ctx, metaAccount, string(b)); err != nil {
return nil, err
}
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
return c, nil
}
// provider publishes the challenge through Jarl.
type provider struct {
m *Manager
ctx context.Context
}
func (p *provider) set(domain, keyAuth string, set bool) error {
info := dns01.GetChallengeInfo(domain, keyAuth)
ctx := p.ctx
if !set {
ctx = context.WithoutCancel(ctx)
}
all, err := p.m.jarl.Zones(ctx)
if err != nil {
return err
}
zones := jarl.ZonesFor(info.EffectiveFQDN, all, "")
if len(zones) == 0 {
return fmt.Errorf("no primary zone in Jarl holds %s", strings.TrimSuffix(info.EffectiveFQDN, "."))
}
return p.m.jarl.SetTXT(ctx, info.EffectiveFQDN, info.Value, zones, set, false, "Steward's certificate")
}
func (p *provider) Present(domain, token, keyAuth string) error { return p.set(domain, keyAuth, true) }
func (p *provider) CleanUp(domain, token, keyAuth string) error { return p.set(domain, keyAuth, false) }
// Timeout is how long lego waits for the record to reach the name servers.
func (p *provider) Timeout() (timeout, interval time.Duration) {
return 5 * time.Minute, 5 * time.Second
}
func (m *Manager) obtain(ctx context.Context) (*certificate.Resource, error) {
c, err := m.client(ctx)
if err != nil {
return nil, err
}
opts := []dns01.ChallengeOption{dns01.AddDNSTimeout(10 * time.Second)}
if len(m.cfg.ACME.Resolvers) > 0 {
opts = append(opts, dns01.AddRecursiveNameservers(m.cfg.ACME.Resolvers))
}
if !m.cfg.ACME.CheckPropagation {
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
}
if err := c.Challenge.SetDNS01Provider(&provider{m: m, ctx: ctx}, opts...); err != nil {
return nil, err
}
res, err := c.Certificate.Obtain(certificate.ObtainRequest{Domains: []string{m.cfg.ACME.Domain}, Bundle: true})
if err != nil {
return nil, cleanLegoError(err)
}
return res, nil
}
// cleanLegoError shortens lego's errors (as Jarl does): "[name] acme:
// error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG" becomes
// "name: the CA refused (unauthorized): MSG".
func cleanLegoError(err error) error {
msg := strings.TrimSpace(strings.Replace(err.Error(), "error: one or more domains had a problem:", "", 1))
var out []string
for _, line := range strings.Split(msg, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
domain := ""
for strings.HasPrefix(line, "[") {
end := strings.Index(line, "] ")
if end < 0 {
break
}
if domain == "" {
domain = line[1:end]
}
line = line[end+2:]
}
for _, p := range []string{"acme: error presenting token: ", "acme: error: ", "acme: "} {
line = strings.TrimPrefix(line, p)
}
if mm := problemRe.FindStringSubmatch(line); mm != nil {
line = "the CA refused (" + mm[2] + "): " + mm[3]
}
if domain != "" {
line = domain + ": " + line
}
out = append(out, line)
}
if len(out) == 0 {
return err
}
return errors.New(strings.Join(out, "; "))
}
var problemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)