Jarl is now an ACME client (lego's core, no DNS providers). It answers DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while Porkbun serves the domain; "auto" decides from the domain's public name servers at every renewal, and a CNAME on _acme-challenge is followed. Certificates are used for jarld's HTTPS (served without a restart; each port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted proxies still served) and/or written for named's DNS over TLS/HTTPS, with rndc reconfig on renewal. Renewal follows the CA's ARI window or two thirds of the lifetime, with back-off. Certificates you renew yourself can be used too. Certificates page and dashboard card, jarl cert, /api/v1/certificates, Prometheus gauges, docs/certificates.md, and an e2e section against Pebble. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
13 KiB
Go
407 lines
13 KiB
Go
package certs
|
|
|
|
import (
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-acme/lego/v4/certcrypto"
|
|
"github.com/go-acme/lego/v4/certificate"
|
|
"github.com/go-acme/lego/v4/challenge/dns01"
|
|
"github.com/go-acme/lego/v4/lego"
|
|
"github.com/go-acme/lego/v4/registration"
|
|
|
|
"jarl/internal/acme"
|
|
"jarl/internal/auth"
|
|
"jarl/internal/engine"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// Account is the ACME account certificates are obtained with. Its key is
|
|
// kept in Jarl's database (and so in backups), like TSIG secrets.
|
|
type Account struct {
|
|
Directory string `json:"directory"`
|
|
Email string `json:"email,omitempty"`
|
|
URI string `json:"uri,omitempty"` // the account at the CA
|
|
RegisteredAt time.Time `json:"registered_at"`
|
|
KeyPEM string `json:"key_pem,omitempty"`
|
|
}
|
|
|
|
const metaAccount = "acme.account"
|
|
|
|
// account returns the stored account (nil when there is none).
|
|
func (m *Manager) account(ctx context.Context) *Account {
|
|
raw, err := m.store.GetMeta(ctx, metaAccount)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var a Account
|
|
if json.Unmarshal([]byte(raw), &a) != nil {
|
|
return nil
|
|
}
|
|
return &a
|
|
}
|
|
|
|
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 }
|
|
|
|
// ErrTermsNotAgreed means acme.account.agree_tos isn't set.
|
|
var ErrTermsNotAgreed = errors.New("acme.account.agree_tos isn't set")
|
|
|
|
func (m *Manager) httpClient() (*http.Client, error) {
|
|
tr := http.DefaultTransport.(*http.Transport).Clone()
|
|
tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
|
if m.cfg.CABundle != "" {
|
|
pem, err := os.ReadFile(m.cfg.CABundle)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme.ca_bundle: %w", err)
|
|
}
|
|
pool, err := x509.SystemCertPool()
|
|
if err != nil || pool == nil {
|
|
pool = x509.NewCertPool()
|
|
}
|
|
if !pool.AppendCertsFromPEM(pem) {
|
|
return nil, fmt.Errorf("acme.ca_bundle: no certificates in %s", m.cfg.CABundle)
|
|
}
|
|
tr.TLSClientConfig.RootCAs = pool
|
|
}
|
|
return &http.Client{Timeout: 2 * time.Minute, Transport: tr}, nil
|
|
}
|
|
|
|
// client returns a lego client for the account (registering it when
|
|
// needed) that makes keys of keyType.
|
|
func (m *Manager) client(ctx context.Context, keyType string) (*lego.Client, error) {
|
|
dir := m.cfg.Directory
|
|
hc, err := m.httpClient()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a := m.account(ctx)
|
|
user := &legoUser{email: m.cfg.Email}
|
|
if a != nil && a.Directory == dir && a.KeyPEM != "" {
|
|
if user.key, err = parseKey(a.KeyPEM); err != nil {
|
|
return nil, fmt.Errorf("the stored ACME account key: %w", err)
|
|
}
|
|
if a.URI != "" {
|
|
user.reg = ®istration.Resource{URI: a.URI}
|
|
}
|
|
} else {
|
|
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
user.key = k
|
|
}
|
|
cfg := lego.NewConfig(user)
|
|
cfg.CADirURL, cfg.HTTPClient, cfg.UserAgent = dir, hc, "jarl/"+m.cfg.Version
|
|
cfg.Certificate.KeyType = legoKeyType(keyType)
|
|
cfg.Certificate.Timeout = 90 * time.Second
|
|
c, err := lego.NewClient(cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("ACME server %s: %w", dir, err)
|
|
}
|
|
if user.reg != nil && (a.Email == m.cfg.Email) {
|
|
return c, nil
|
|
}
|
|
if user.reg != nil {
|
|
// Only the email changed: update the account.
|
|
if reg, err := c.Registration.UpdateRegistration(registration.RegisterOptions{TermsOfServiceAgreed: true}); err == nil {
|
|
user.reg = reg
|
|
return c, m.saveAccount(ctx, user, dir)
|
|
}
|
|
}
|
|
if !m.cfg.AgreeTOS {
|
|
tos := c.GetToSURL()
|
|
if tos == "" {
|
|
tos = "on the CA's website"
|
|
}
|
|
return nil, fmt.Errorf("%w: Jarl registers an account with %s only after you accept its terms of service (%s): set acme.account.agree_tos: true in jarl.yaml and restart jarld", ErrTermsNotAgreed, dir, tos)
|
|
}
|
|
var reg *registration.Resource
|
|
if m.cfg.EABKeyID != "" {
|
|
reg, err = c.Registration.RegisterWithExternalAccountBinding(registration.RegisterEABOptions{TermsOfServiceAgreed: true, Kid: m.cfg.EABKeyID, HmacEncoded: m.cfg.EABHMAC})
|
|
} else {
|
|
if c.GetExternalAccountRequired() {
|
|
return nil, fmt.Errorf("%s needs External Account Binding: set acme.account.eab_kid and eab_hmac in jarl.yaml (from the CA's dashboard)", dir)
|
|
}
|
|
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
|
|
m.log.Info("ACME account registered", "directory", dir, "account", reg.URI)
|
|
return c, m.saveAccount(ctx, user, dir)
|
|
}
|
|
|
|
func (m *Manager) saveAccount(ctx context.Context, u *legoUser, dir string) error {
|
|
der, err := x509.MarshalPKCS8PrivateKey(u.key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a := Account{Directory: dir, Email: u.email, URI: u.reg.URI, RegisteredAt: time.Now().UTC(),
|
|
KeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))}
|
|
b, _ := json.Marshal(a)
|
|
return m.store.SetMeta(ctx, metaAccount, string(b))
|
|
}
|
|
|
|
func parseKey(s string) (crypto.PrivateKey, error) {
|
|
b, _ := pem.Decode([]byte(s))
|
|
if b == nil {
|
|
return nil, errors.New("not PEM")
|
|
}
|
|
return x509.ParsePKCS8PrivateKey(b.Bytes)
|
|
}
|
|
|
|
func legoKeyType(t string) certcrypto.KeyType {
|
|
switch t {
|
|
case "ec384":
|
|
return certcrypto.EC384
|
|
case "rsa2048":
|
|
return certcrypto.RSA2048
|
|
case "rsa3072":
|
|
return certcrypto.RSA3072
|
|
case "rsa4096":
|
|
return certcrypto.RSA4096
|
|
}
|
|
return certcrypto.EC256
|
|
}
|
|
|
|
// KeyTypes are the key types a certificate Jarl obtains can have.
|
|
var KeyTypes = []string{"ec256", "ec384", "rsa2048", "rsa3072", "rsa4096"}
|
|
|
|
// provider answers DNS-01 challenges for one issue: in a zone Jarl serves,
|
|
// or at Porkbun, as route decides for each name.
|
|
type provider struct {
|
|
m *Manager
|
|
ctx context.Context
|
|
cert *model.Certificate
|
|
mu sync.Mutex
|
|
cleanups map[string]func() error
|
|
routes []Route
|
|
}
|
|
|
|
func (p *provider) Present(domain, token, keyAuth string) error {
|
|
info := dns01.GetChallengeInfo(domain, keyAuth)
|
|
st, err := p.m.store.LoadState(p.ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r := p.m.route(p.ctx, st, p.cert.Challenge, domain, info.EffectiveFQDN)
|
|
p.mu.Lock()
|
|
p.routes = append(p.routes, r)
|
|
p.mu.Unlock()
|
|
if r.Error != "" {
|
|
return errors.New(r.Error)
|
|
}
|
|
key := info.EffectiveFQDN + " " + info.Value
|
|
var cleanup func() error
|
|
switch r.Route {
|
|
case model.ChallengeJarl:
|
|
if err := p.m.publish(p.ctx, p.cert, info.EffectiveFQDN, info.Value, true); err != nil {
|
|
return err
|
|
}
|
|
cleanup = func() error {
|
|
return p.m.publish(context.WithoutCancel(p.ctx), p.cert, info.EffectiveFQDN, info.Value, false)
|
|
}
|
|
case model.ChallengePorkbun:
|
|
zone := strings.TrimSuffix(r.Zone, ".")
|
|
id, err := p.m.cfg.Porkbun.CreateTXT(p.ctx, zone, strings.TrimSuffix(info.EffectiveFQDN, "."), info.Value)
|
|
if err != nil {
|
|
return fmt.Errorf("publishing the challenge for %s at Porkbun: %w", domain, err)
|
|
}
|
|
cleanup = func() error { return p.m.cfg.Porkbun.DeleteRecord(context.WithoutCancel(p.ctx), zone, id) }
|
|
}
|
|
p.mu.Lock()
|
|
p.cleanups[key] = cleanup
|
|
p.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (p *provider) CleanUp(domain, token, keyAuth string) error {
|
|
info := dns01.GetChallengeInfo(domain, keyAuth)
|
|
key := info.EffectiveFQDN + " " + info.Value
|
|
p.mu.Lock()
|
|
f := p.cleanups[key]
|
|
delete(p.cleanups, key)
|
|
p.mu.Unlock()
|
|
if f == nil {
|
|
return nil
|
|
}
|
|
if err := f(); err != nil {
|
|
p.m.log.Warn("removing an ACME challenge record failed", "certificate", p.cert.Name, "record", info.EffectiveFQDN, "err", err)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Timeout is how long lego waits for the record to reach every name
|
|
// server of the zone: Porkbun's take a few minutes.
|
|
func (p *provider) Timeout() (timeout, interval time.Duration) {
|
|
if p.m.cfg.PropagationTimeout > 0 {
|
|
return p.m.cfg.PropagationTimeout, 2 * time.Second
|
|
}
|
|
return 10 * time.Minute, 10 * time.Second
|
|
}
|
|
|
|
// publish writes (or removes) a challenge TXT record in the Jarl zone that
|
|
// holds name, in every view that has it, and returns once named serves it.
|
|
func (m *Manager) publish(ctx context.Context, c *model.Certificate, name, value string, set bool) error {
|
|
st, err := m.store.LoadState(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
zones := acme.Zones(name, primaryZones(st), "")
|
|
if len(zones) == 0 {
|
|
return fmt.Errorf("no primary zone in Jarl contains %s", strings.TrimSuffix(name, "."))
|
|
}
|
|
var reqs []engine.Request
|
|
for _, r := range acme.Requests(name, zones, []string{value}, set, acme.DefaultTTL) {
|
|
reqs = append(reqs, engine.Request{Op: r.Op, ID: r.ID, BaseVersion: r.BaseVersion, Data: r.Data})
|
|
}
|
|
desc := acme.Description(name, set) + " (certificate " + c.Name + ")"
|
|
if _, err := m.eng.Submit(ctx, reqs, auth.System, "acme", desc, engine.ApplyOptions{}); err != nil {
|
|
return fmt.Errorf("writing the challenge record %s: %w", strings.TrimSuffix(name, "."), err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// obtain gets a new certificate for c from the CA. old is the certificate
|
|
// it replaces (nil for the first), so the CA can link them (ARI).
|
|
func (m *Manager) obtain(ctx context.Context, c *model.Certificate, old *x509.Certificate) (*certificate.Resource, []Route, error) {
|
|
m.legoMu.Lock()
|
|
defer m.legoMu.Unlock()
|
|
client, err := m.client(ctx, c.KeyType)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
p := &provider{m: m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
|
|
opts := []dns01.ChallengeOption{dns01.AddRecursiveNameservers(m.cfg.Resolvers), dns01.AddDNSTimeout(10 * time.Second)}
|
|
if m.cfg.SkipPropagationCheck {
|
|
opts = append(opts, dns01.DisableAuthoritativeNssPropagationRequirement())
|
|
}
|
|
if err := client.Challenge.SetDNS01Provider(p, opts...); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
req := certificate.ObtainRequest{Domains: c.Domains, Bundle: true}
|
|
if old != nil {
|
|
if id, err := certificate.MakeARICertID(old); err == nil {
|
|
req.ReplacesCertID = id
|
|
}
|
|
}
|
|
res, err := client.Certificate.Obtain(req)
|
|
if err != nil && req.ReplacesCertID != "" && len(p.routes) == 0 && ctx.Err() == nil {
|
|
// The CA refused the order itself, before any challenge: it may
|
|
// not link the new certificate to the old one (another CA, one
|
|
// already replaced, or its own bug). Ask for a plain new one.
|
|
m.log.Info("the CA refused to link the renewal to the old certificate; asking for a new one", "certificate", c.Name, "err", cleanLegoError(err))
|
|
req.ReplacesCertID = ""
|
|
res, err = client.Certificate.Obtain(req)
|
|
}
|
|
if err != nil {
|
|
return nil, p.routes, cleanLegoError(err)
|
|
}
|
|
return res, p.routes, nil
|
|
}
|
|
|
|
// renewalInfo asks the CA when to renew (ARI, RFC 9773). nil, nil when
|
|
// it doesn't say.
|
|
func (m *Manager) renewalInfo(ctx context.Context, c *model.Certificate, leaf *x509.Certificate) (*time.Time, error) {
|
|
m.legoMu.Lock()
|
|
defer m.legoMu.Unlock()
|
|
client, err := m.client(ctx, c.KeyType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ri, err := client.Certificate.GetRenewalInfo(certificate.RenewalInfoRequest{Cert: leaf})
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "does not advertise a renewal info endpoint") {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
// A time within the CA's window (now if it has started); never later
|
|
// than two thirds into the lifetime, in case the window is odd.
|
|
at := ri.ShouldRenewAt(time.Now(), 10*365*24*time.Hour)
|
|
if at == nil {
|
|
return nil, nil
|
|
}
|
|
t := at.UTC().Truncate(time.Second)
|
|
if limit := twoThirds(leaf.NotBefore, leaf.NotAfter); t.After(limit) {
|
|
t = limit
|
|
}
|
|
return &t, nil
|
|
}
|
|
|
|
func twoThirds(nb, na time.Time) time.Time {
|
|
return nb.Add(na.Sub(nb) * 2 / 3).UTC().Truncate(time.Second)
|
|
}
|
|
|
|
// cleanLegoError shortens lego's errors, which wrap each name's problem in
|
|
// several prefixes and the whole ACME problem document:
|
|
//
|
|
// error: one or more domains had a problem:
|
|
// [a.example.com] [a.example.com] acme: error presenting token: MSG
|
|
// [b.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: MSG
|
|
//
|
|
// becomes "a.example.com: MSG; b.example.com: 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 m := acmeProblemRe.FindStringSubmatch(line); m != nil {
|
|
line = "the CA refused (" + m[2] + "): " + m[3]
|
|
}
|
|
if domain != "" {
|
|
line = domain + ": " + line
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
if len(out) == 0 {
|
|
return err
|
|
}
|
|
return errors.New(strings.Join(out, "; "))
|
|
}
|
|
|
|
// acmeProblemRe matches "403 :: urn:ietf:params:acme:error:unauthorized :: detail".
|
|
var acmeProblemRe = regexp.MustCompile(`^(\d+) :: urn:ietf:params:acme:error:(\w+) :: (.*)$`)
|