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>
485 lines
14 KiB
Go
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 = ®istration.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+) :: (.*)$`)
|