// 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+) :: (.*)$`)