TLS certificates: Jarl obtains and renews them over ACME
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>
This commit is contained in:
1 parent
88965096bb
commit
88f63ffb0a
43 files changed
+4584
-45
No files matched your search
@@ -0,0 +1,406 @@
|
||||
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+) :: (.*)$`)
|
||||
@@ -0,0 +1,490 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// fakeDNS answers like the internet would: zones with their public name
|
||||
// servers, CNAME records, and TXT records (Porkbun's, from the fake API).
|
||||
type fakeDNS struct {
|
||||
mu sync.Mutex
|
||||
zones map[string][]string // zone -> NS
|
||||
cnames map[string]string
|
||||
txt func(name string) []string
|
||||
addr string
|
||||
}
|
||||
|
||||
func newFakeDNS(t *testing.T) *fakeDNS {
|
||||
f := &fakeDNS{zones: map[string][]string{}, cnames: map[string]string{}, txt: func(string) []string { return nil }}
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &dns.Server{PacketConn: pc, Handler: f}
|
||||
go srv.ActivateAndServe()
|
||||
t.Cleanup(func() { srv.Shutdown() })
|
||||
f.addr = pc.LocalAddr().String()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeDNS) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
q := r.Question[0]
|
||||
name := strings.ToLower(q.Name)
|
||||
zone := ""
|
||||
for z := range f.zones {
|
||||
if dns.IsSubDomain(z, name) && len(z) > len(zone) {
|
||||
zone = z
|
||||
}
|
||||
}
|
||||
if zone == "" {
|
||||
m.Rcode = dns.RcodeNameError
|
||||
w.WriteMsg(m)
|
||||
return
|
||||
}
|
||||
soa := &dns.SOA{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 60}, Ns: f.zones[zone][0], Mbox: "h." + zone, Serial: 1, Refresh: 1, Retry: 1, Expire: 1, Minttl: 60}
|
||||
switch {
|
||||
case q.Qtype == dns.TypeSOA && name == zone:
|
||||
m.Answer = append(m.Answer, soa)
|
||||
case q.Qtype == dns.TypeNS && name == zone:
|
||||
for _, ns := range f.zones[zone] {
|
||||
m.Answer = append(m.Answer, &dns.NS{Hdr: dns.RR_Header{Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: 60}, Ns: ns})
|
||||
}
|
||||
case f.cnames[name] != "":
|
||||
m.Answer = append(m.Answer, &dns.CNAME{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeCNAME, Class: dns.ClassINET, Ttl: 60}, Target: f.cnames[name]})
|
||||
case q.Qtype == dns.TypeTXT && len(f.txt(name)) > 0:
|
||||
for _, v := range f.txt(name) {
|
||||
m.Answer = append(m.Answer, &dns.TXT{Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 60}, Txt: []string{v}})
|
||||
}
|
||||
default:
|
||||
m.Ns = append(m.Ns, soa)
|
||||
}
|
||||
w.WriteMsg(m)
|
||||
}
|
||||
|
||||
type env struct {
|
||||
t *testing.T
|
||||
m *Manager
|
||||
st *store.Store
|
||||
eng *engine.Engine
|
||||
dns *fakeDNS
|
||||
pb *porkbun.Fake
|
||||
dir string
|
||||
}
|
||||
|
||||
// newEnv sets up Jarl with zone example.com (name server
|
||||
// ns1.example.com, which the internet also uses), a fake internet and a
|
||||
// fake Porkbun account with pb.test.
|
||||
func newEnv(t *testing.T, withPorkbun bool) *env {
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "jarl.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
eng := engine.New(st, nil, engine.Config{
|
||||
Paths: render.Paths{ConfigDir: filepath.Join(dir, "etc"), ZoneDir: filepath.Join(dir, "zones"), CacheDir: filepath.Join(dir, "cache")},
|
||||
StagingDir: filepath.Join(dir, "staging"), NoReload: true, SkipCheck: true,
|
||||
}, quiet)
|
||||
e := &env{t: t, st: st, eng: eng, dns: newFakeDNS(t), dir: dir}
|
||||
e.submit(engine.NewRequest(engine.OpSettingsUpdate, 0, engine.SettingsInput{Nameservers: &[]string{"ns1.example.net."}}),
|
||||
engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("example.com")}))
|
||||
e.dns.mu.Lock()
|
||||
e.dns.zones["example.com."] = []string{"ns1.example.net."}
|
||||
e.dns.zones["pb.test."] = []string{"curitiba.ns.porkbun.com.", "fortaleza.ns.porkbun.com."}
|
||||
e.dns.zones["other.test."] = []string{"a.ns.cloudflare.com."}
|
||||
e.dns.mu.Unlock()
|
||||
cfg := Config{Resolvers: []string{e.dns.addr}, Dir: filepath.Join(dir, "tls"), Directory: "https://acme.invalid/dir"}
|
||||
if withPorkbun {
|
||||
e.pb = porkbun.NewFake("pk1_test", "sk1_test", "pb.test")
|
||||
ts := httptest.NewServer(e.pb)
|
||||
t.Cleanup(ts.Close)
|
||||
cfg.Porkbun = &porkbun.Client{BaseURL: ts.URL + "/api/json/v3", APIKey: "pk1_test", Secret: "sk1_test"}
|
||||
e.dns.mu.Lock()
|
||||
e.dns.txt = func(name string) []string { return e.pb.TXT(name) }
|
||||
e.dns.mu.Unlock()
|
||||
}
|
||||
e.m = New(cfg, st, eng, quiet)
|
||||
// lego's own lookups (CNAME following) use the fake internet too.
|
||||
dns01.AddRecursiveNameservers([]string{e.dns.addr})(nil)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *env) submit(reqs ...engine.Request) {
|
||||
e.t.Helper()
|
||||
if _, err := e.eng.Submit(context.Background(), reqs, auth.System, "test", "", engine.ApplyOptions{}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *env) state() *model.State {
|
||||
st, err := e.st.LoadState(context.Background())
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func TestRoute(t *testing.T) {
|
||||
e := newEnv(t, true)
|
||||
ctx := context.Background()
|
||||
e.dns.mu.Lock()
|
||||
e.dns.cnames["_acme-challenge.www.other.test."] = "www-other.acme.example.com."
|
||||
e.dns.mu.Unlock()
|
||||
cases := []struct {
|
||||
challenge, domain string
|
||||
route string // "" for an error
|
||||
target string
|
||||
errHas string
|
||||
}{
|
||||
{"auto", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
|
||||
{"auto", "*.example.com", "jarl", "_acme-challenge.example.com.", ""},
|
||||
{"auto", "ns1.pb.test", "porkbun", "_acme-challenge.ns1.pb.test.", ""},
|
||||
{"auto", "www.other.test", "jarl", "www-other.acme.example.com.", ""},
|
||||
{"auto", "mail.other.test", "", "", "Point _acme-challenge.mail.other.test with a CNAME"},
|
||||
{"porkbun", "www.example.com", "", "", "not Porkbun"},
|
||||
{"jarl", "ns1.pb.test", "", "", "no primary zone in Jarl is pb.test"},
|
||||
{"jarl", "www.example.com", "jarl", "_acme-challenge.www.example.com.", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
r := e.m.route(ctx, e.state(), c.challenge, c.domain, "")
|
||||
if r.Route != c.route || c.target != "" && r.Target != c.target || c.errHas != "" && !strings.Contains(r.Error, c.errHas) {
|
||||
t.Errorf("%s %s: route %q target %q error %q", c.challenge, c.domain, r.Route, r.Target, r.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// The move from Porkbun to Jarl: while the internet still asks
|
||||
// Porkbun, auto goes there even though Jarl has the zone; once the
|
||||
// name servers change, it comes to Jarl.
|
||||
e.submit(engine.NewRequest(engine.OpZoneCreate, 0, engine.ZoneInput{Name: model.Ptr("pb.test")}))
|
||||
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "porkbun" {
|
||||
t.Errorf("before the move: %+v", r)
|
||||
}
|
||||
if r := e.m.route(ctx, e.state(), "jarl", "ns1.pb.test", ""); !strings.Contains(r.Error, "not by the name servers in Jarl's zone (ns1.example.net)") {
|
||||
t.Errorf("jarl before the move: %+v", r)
|
||||
}
|
||||
e.dns.mu.Lock()
|
||||
e.dns.zones["pb.test."] = []string{"ns1.example.net."}
|
||||
e.dns.mu.Unlock()
|
||||
if r := e.m.route(ctx, e.state(), "auto", "ns1.pb.test", ""); r.Route != "jarl" {
|
||||
t.Errorf("after the move: %+v", r)
|
||||
}
|
||||
if r := e.m.route(ctx, e.state(), "porkbun", "ns1.pb.test", ""); !strings.Contains(r.Error, "not Porkbun") {
|
||||
t.Errorf("porkbun after the move: %+v", r)
|
||||
}
|
||||
|
||||
// Without a Porkbun key, a Porkbun domain says what is missing.
|
||||
e2 := newEnv(t, false)
|
||||
if r := e2.m.route(ctx, e2.state(), "auto", "ns1.pb.test", ""); !strings.Contains(r.Error, "no porkbun.api_key") {
|
||||
t.Errorf("without a key: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider(t *testing.T) {
|
||||
e := newEnv(t, true)
|
||||
ctx := context.Background()
|
||||
c := &model.Certificate{Name: "t", Challenge: "auto"}
|
||||
p := &provider{m: e.m, ctx: ctx, cert: c, cleanups: map[string]func() error{}}
|
||||
|
||||
// Porkbun: the record goes in through the API and is removed again.
|
||||
info := dns01.GetChallengeInfo("ns1.pb.test", "key-auth-1")
|
||||
if err := p.Present("ns1.pb.test", "tok", "key-auth-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 1 || got[0] != info.Value {
|
||||
t.Fatalf("Porkbun TXT = %v, want %s", got, info.Value)
|
||||
}
|
||||
if err := p.CleanUp("ns1.pb.test", "tok", "key-auth-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := e.pb.TXT("_acme-challenge.ns1.pb.test"); len(got) != 0 {
|
||||
t.Fatalf("Porkbun TXT after cleanup = %v", got)
|
||||
}
|
||||
|
||||
// Jarl: the record goes into the zone, in a changeset by the system.
|
||||
txt := func() []string {
|
||||
var out []string
|
||||
st := e.state()
|
||||
for _, r := range st.Records {
|
||||
if r.Type == "TXT" && r.Name == "_acme-challenge.www.example.com." {
|
||||
out = append(out, r.Data)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
info = dns01.GetChallengeInfo("www.example.com", "key-auth-2")
|
||||
if err := p.Present("www.example.com", "tok", "key-auth-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := txt(); len(got) != 1 || !strings.Contains(got[0], info.Value) {
|
||||
t.Fatalf("Jarl TXT = %v, want %s", got, info.Value)
|
||||
}
|
||||
if err := p.CleanUp("www.example.com", "tok", "key-auth-2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := txt(); len(got) != 0 {
|
||||
t.Fatalf("Jarl TXT after cleanup = %v", got)
|
||||
}
|
||||
if len(p.routes) != 2 || p.routes[0].Route != "porkbun" || p.routes[1].Route != "jarl" {
|
||||
t.Errorf("routes = %+v", p.routes)
|
||||
}
|
||||
|
||||
// A name nobody can answer for fails before anything is published.
|
||||
if err := p.Present("mail.other.test", "tok", "k"); err == nil || !strings.Contains(err.Error(), "CNAME") {
|
||||
t.Errorf("other.test: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// makeCert returns a certificate for names (signed by a CA unless
|
||||
// selfSigned) and its key, as PEM; withCA appends the CA certificate.
|
||||
func makeCert(t *testing.T, names []string, notBefore, notAfter time.Time, withCA bool) (certPEM, keyPEM []byte) {
|
||||
t.Helper()
|
||||
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "Test CA", Organization: []string{"Jarl"}},
|
||||
NotBefore: notBefore, NotAfter: notAfter.Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
|
||||
caDER, _ := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey)
|
||||
caCert, _ := x509.ParseCertificate(caDER)
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
leaf := &x509.Certificate{SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
|
||||
NotBefore: notBefore, NotAfter: notAfter, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}
|
||||
der, err := x509.CreateCertificate(rand.Reader, leaf, caCert, &key.PublicKey, caKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
if withCA {
|
||||
certPEM = append(certPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...)
|
||||
}
|
||||
kd, _ := x509.MarshalPKCS8PrivateKey(key)
|
||||
return certPEM, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd})
|
||||
}
|
||||
|
||||
func TestInspect(t *testing.T) {
|
||||
now := time.Now()
|
||||
cert, key := makeCert(t, []string{"ns1.example.com", "*.example.com"}, now, now.Add(90*24*time.Hour), true)
|
||||
in, pair, err := Inspect(cert, key)
|
||||
if err != nil || pair == nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(in.Names, " ") != "ns1.example.com *.example.com" || in.Issuer != "Jarl Test CA" || in.KeyType != "ec256" || len(in.Warnings) != 0 {
|
||||
t.Errorf("info = %+v", in)
|
||||
}
|
||||
leafOnly, _ := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
|
||||
_, otherKey := makeCert(t, []string{"b.example.com"}, now, now.Add(time.Hour), false)
|
||||
if _, _, err := Inspect(cert, otherKey); err == nil || !strings.Contains(err.Error(), "doesn't belong") {
|
||||
t.Errorf("mismatched key: %v", err)
|
||||
}
|
||||
leafCert, leafKey := makeCert(t, []string{"a.example.com"}, now, now.Add(time.Hour), false)
|
||||
if in, _, err := Inspect(leafCert, leafKey); err != nil || len(in.Warnings) != 1 || !strings.Contains(in.Warnings[0], "full chain") {
|
||||
t.Errorf("leaf only: %+v %v", in, err)
|
||||
}
|
||||
_ = leafOnly
|
||||
if !Covers([]string{"*.example.com"}, []string{"www.example.com"}) || Covers([]string{"*.example.com"}, []string{"a.b.example.com"}) || Covers([]string{"example.com"}, []string{"www.example.com"}) {
|
||||
t.Error("Covers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueAndStatus(t *testing.T) {
|
||||
m := &Manager{now: time.Now}
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
nb, na := now.Add(-80*24*time.Hour), now.Add(10*24*time.Hour)
|
||||
c := &model.Certificate{Source: model.CertSourceACME, CreatedAt: now.Add(-time.Hour)}
|
||||
if got := m.Due(c); !got.Equal(c.CreatedAt) {
|
||||
t.Errorf("never obtained: due %v", got)
|
||||
}
|
||||
if s := m.StatusOf(c); s.State != "pending" {
|
||||
t.Errorf("pending: %+v", s)
|
||||
}
|
||||
c.CertPEM, c.NotBefore, c.NotAfter = "x", &nb, &na
|
||||
if got, want := m.Due(c), twoThirds(nb, na); !got.Equal(want) {
|
||||
t.Errorf("due %v, want two thirds %v", got, want)
|
||||
}
|
||||
ari := now.Add(time.Hour)
|
||||
c.RenewAt = &ari
|
||||
if got := m.Due(c); !got.Equal(ari) {
|
||||
t.Errorf("due %v, want the CA's %v", got, ari)
|
||||
}
|
||||
// 10 days left of 90 is less than a sixth: expiring.
|
||||
if s := m.StatusOf(c); s.State != "expiring" {
|
||||
t.Errorf("status %+v", s)
|
||||
}
|
||||
// After three failures it waits four hours from the last attempt.
|
||||
last := now.Add(-time.Hour)
|
||||
c.Failures, c.LastAttempt, c.LastError = 3, &last, "boom"
|
||||
if got := m.Due(c); !got.Equal(last.Add(4 * time.Hour)) {
|
||||
t.Errorf("backoff: due %v", got)
|
||||
}
|
||||
if s := m.StatusOf(c); s.State != "failing" || !strings.Contains(s.Message, "boom") {
|
||||
t.Errorf("failing: %+v", s)
|
||||
}
|
||||
c.Failures = 30
|
||||
if got := m.Due(c); !got.Equal(last.Add(24 * time.Hour)) {
|
||||
t.Errorf("backoff is at most a day: due %v", got)
|
||||
}
|
||||
old := now.Add(-time.Hour)
|
||||
c.NotAfter = &old
|
||||
if s := m.StatusOf(c); s.State != "expired" {
|
||||
t.Errorf("expired: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInput(t *testing.T) {
|
||||
e := newEnv(t, false)
|
||||
ctx := context.Background()
|
||||
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||
bad := []Input{
|
||||
{Name: model.Ptr("Bad Name"), Domains: &[]string{"a.example.com"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"localhost"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.*.example.com"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"mail"}},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, Challenge: model.Ptr("http")},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, KeyType: model.Ptr("rsa1024")},
|
||||
{Name: model.Ptr("x"), Source: model.Ptr("file"), CertFile: model.Ptr("rel.pem"), KeyFile: model.Ptr("/k.pem")},
|
||||
{Name: model.Ptr("x"), Domains: &[]string{"a.example.com"}, CertFile: model.Ptr("/c.pem")},
|
||||
}
|
||||
for _, in := range bad {
|
||||
if _, err := e.m.Create(ctx, in, admin); err == nil {
|
||||
t.Errorf("accepted %+v", in)
|
||||
}
|
||||
}
|
||||
c, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"WWW.Example.com.", "www.example.com", "*.example.com"}, Uses: &[]string{"web", "dns", "web"}}, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(c.Domains, " ") != "www.example.com *.example.com" || strings.Join(c.Uses, ",") != "dns,web" || c.Challenge != "auto" || c.KeyType != "ec256" {
|
||||
t.Errorf("normalized: %+v", c)
|
||||
}
|
||||
// Only one certificate serves jarld's HTTPS.
|
||||
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web2"), Domains: &[]string{"a.example.com"}, Uses: &[]string{"web"}}, admin); err == nil || !strings.Contains(err.Error(), "already used for jarld's HTTPS") {
|
||||
t.Errorf("second web certificate: %v", err)
|
||||
}
|
||||
if _, err := e.m.Create(ctx, Input{Name: model.Ptr("web"), Domains: &[]string{"a.example.com"}}, admin); err != ErrConflict {
|
||||
t.Errorf("duplicate name: %v", err)
|
||||
}
|
||||
if _, err := e.m.Update(ctx, "web", Input{Name: model.Ptr("renamed")}, admin); err == nil {
|
||||
t.Error("rename accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSource(t *testing.T) {
|
||||
e := newEnv(t, false)
|
||||
ctx := context.Background()
|
||||
admin := &auth.Principal{User: &model.User{Username: "admin", Admin: true}}
|
||||
now := time.Now()
|
||||
certFile, keyFile := filepath.Join(e.dir, "fullchain.pem"), filepath.Join(e.dir, "privkey.pem")
|
||||
cert, key := makeCert(t, []string{"ns1.example.com"}, now.Add(-time.Hour), now.Add(60*24*time.Hour), true)
|
||||
os.WriteFile(certFile, cert, 0o600)
|
||||
os.WriteFile(keyFile, key, 0o600)
|
||||
|
||||
c, err := e.m.Create(ctx, Input{Name: model.Ptr("own"), Source: model.Ptr("file"), CertFile: &certFile, KeyFile: &keyFile, Uses: &[]string{"web", "dns"}}, admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ = e.m.Get(ctx, "own")
|
||||
if strings.Join(c.Domains, " ") != "ns1.example.com" || c.NotAfter == nil || c.CertPEM != "" {
|
||||
t.Errorf("read: %+v", c)
|
||||
}
|
||||
if e.m.WebCertificate() == nil || e.m.WebName() != "own" {
|
||||
t.Error("not served for HTTPS")
|
||||
}
|
||||
chain := filepath.Join(e.m.FilesDir("own"), ChainFile)
|
||||
if b, err := os.ReadFile(chain); err != nil || string(b) != string(cert) {
|
||||
t.Fatalf("files for named: %v", err)
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(e.m.FilesDir("own"), KeyFile)); err != nil || fi.Mode().Perm() != 0o640 {
|
||||
t.Errorf("key file: %v %v", fi, err)
|
||||
}
|
||||
|
||||
// Renewed by its owner: the loop notices and puts the new one in place.
|
||||
cert2, key2 := makeCert(t, []string{"ns1.example.com", "dns.example.com"}, now, now.Add(90*24*time.Hour), true)
|
||||
os.WriteFile(certFile, cert2, 0o600)
|
||||
os.WriteFile(keyFile, key2, 0o600)
|
||||
future := now.Add(time.Minute)
|
||||
os.Chtimes(certFile, future, future)
|
||||
e.m.Tick(ctx)
|
||||
c, _ = e.m.Get(ctx, "own")
|
||||
if strings.Join(c.Domains, " ") != "ns1.example.com dns.example.com" {
|
||||
t.Errorf("not reread: %v", c.Domains)
|
||||
}
|
||||
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
|
||||
t.Error("files for named not updated")
|
||||
}
|
||||
if got := e.m.WebCertificate(); got == nil || len(got.Leaf.DNSNames) != 2 {
|
||||
t.Error("HTTPS still serves the old certificate")
|
||||
}
|
||||
|
||||
// A broken renewal keeps the last good certificate in place.
|
||||
os.WriteFile(keyFile, key, 0o600)
|
||||
os.Chtimes(keyFile, future.Add(time.Minute), future.Add(time.Minute))
|
||||
e.m.Tick(ctx)
|
||||
c, _ = e.m.Get(ctx, "own")
|
||||
if !strings.Contains(c.LastError, "doesn't belong") || e.m.StatusOf(c).State != "failing" {
|
||||
t.Errorf("mismatch not reported: %+v", c)
|
||||
}
|
||||
if b, _ := os.ReadFile(chain); string(b) != string(cert2) {
|
||||
t.Error("files for named replaced by a broken pair")
|
||||
}
|
||||
|
||||
// Without the dns use the files go; deleting stops HTTPS.
|
||||
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
|
||||
// The pair is broken now; put the good key back first.
|
||||
t.Log(err)
|
||||
}
|
||||
os.WriteFile(keyFile, key2, 0o600)
|
||||
if _, err := e.m.Update(ctx, "own", Input{Uses: &[]string{"web"}}, admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(e.m.FilesDir("own")); !os.IsNotExist(err) {
|
||||
t.Errorf("files for named left behind: %v", err)
|
||||
}
|
||||
if err := e.m.Delete(ctx, "own", admin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.m.WebCertificate() != nil {
|
||||
t.Error("still serving a deleted certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanLegoError(t *testing.T) {
|
||||
in := errors.New("error: one or more domains had a problem:\n[www.example.org] [www.example.org] acme: error presenting token: example.org is served by x, which Jarl can't write to\n[a.example.com] acme: error: 403 :: urn:ietf:params:acme:error:unauthorized :: Incorrect TXT record \"abc\" found at _acme-challenge.a.example.com\n")
|
||||
want := `www.example.org: example.org is served by x, which Jarl can't write to; a.example.com: the CA refused (unauthorized): Incorrect TXT record "abc" found at _acme-challenge.a.example.com`
|
||||
if got := cleanLegoError(in).Error(); got != want {
|
||||
t.Errorf("got %s\nwant %s", got, want)
|
||||
}
|
||||
if got := cleanLegoError(errors.New("acme: error: 429 :: urn:ietf:params:acme:error:rateLimited :: too many")).Error(); got != "the CA refused (rateLimited): too many" {
|
||||
t.Errorf("rate limit: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package certs manages TLS certificates (docs/certificates.md): ones Jarl
|
||||
// obtains and renews itself over ACME, answering the DNS-01 challenges in
|
||||
// its own zones or at Porkbun, and ones you renew yourself that Jarl only
|
||||
// reads. A certificate is used for jarld's HTTPS ("web") and/or written
|
||||
// where named can read it ("dns", for DNS over TLS and HTTPS).
|
||||
package certs
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Info is what a certificate says about itself.
|
||||
type Info struct {
|
||||
Names []string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
Issuer string
|
||||
Serial string
|
||||
KeyType string // ec256, ec384, rsa2048, ...
|
||||
Leaf *x509.Certificate
|
||||
// Warnings are problems that don't stop it working everywhere (a
|
||||
// missing intermediate certificate).
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// Inspect checks that certPEM (the certificate, then its intermediates)
|
||||
// and keyPEM belong together and returns what the certificate says, and
|
||||
// the pair ready to serve.
|
||||
func Inspect(certPEM, keyPEM []byte) (*Info, *tls.Certificate, error) {
|
||||
pair, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "private key does not match public key"):
|
||||
return nil, nil, errors.New("the key doesn't belong to the certificate")
|
||||
case strings.Contains(msg, "failed to find any PEM data in certificate"):
|
||||
return nil, nil, errors.New("no certificate found (expected PEM: -----BEGIN CERTIFICATE-----)")
|
||||
case strings.Contains(msg, "failed to find any PEM data in key"), strings.Contains(msg, "failed to find PEM block with type ending in \"PRIVATE KEY\""):
|
||||
return nil, nil, errors.New("no private key found (expected PEM: -----BEGIN ... PRIVATE KEY-----)")
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
leaf := pair.Leaf
|
||||
if leaf == nil {
|
||||
if leaf, err = x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
pair.Leaf = leaf
|
||||
}
|
||||
in := &Info{NotBefore: leaf.NotBefore, NotAfter: leaf.NotAfter, Serial: fmt.Sprintf("%x", leaf.SerialNumber), Leaf: leaf, KeyType: keyType(leaf)}
|
||||
in.Names = append(in.Names, leaf.DNSNames...)
|
||||
for _, ip := range leaf.IPAddresses {
|
||||
in.Names = append(in.Names, ip.String())
|
||||
}
|
||||
if len(in.Names) == 0 && leaf.Subject.CommonName != "" {
|
||||
in.Names = []string{leaf.Subject.CommonName}
|
||||
}
|
||||
in.Issuer = leaf.Issuer.CommonName
|
||||
if len(leaf.Issuer.Organization) > 0 {
|
||||
if in.Issuer == "" {
|
||||
in.Issuer = leaf.Issuer.Organization[0]
|
||||
} else {
|
||||
in.Issuer = leaf.Issuer.Organization[0] + " " + in.Issuer
|
||||
}
|
||||
}
|
||||
selfSigned := leaf.Issuer.String() == leaf.Subject.String()
|
||||
if len(pair.Certificate) == 1 && !selfSigned {
|
||||
in.Warnings = append(in.Warnings, "the file holds only the certificate, without its intermediate certificate: many clients will reject it; use the full chain (fullchain.pem)")
|
||||
}
|
||||
return in, &pair, nil
|
||||
}
|
||||
|
||||
func keyType(c *x509.Certificate) string {
|
||||
switch k := c.PublicKey.(type) {
|
||||
case *ecdsa.PublicKey:
|
||||
return fmt.Sprintf("ec%d", k.Curve.Params().BitSize)
|
||||
case *rsa.PublicKey:
|
||||
return fmt.Sprintf("rsa%d", k.N.BitLen())
|
||||
case ed25519.PublicKey:
|
||||
return "ed25519"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// Covers reports whether the certificate names cover every name in want
|
||||
// (a wildcard covers one label).
|
||||
func Covers(names, want []string) bool {
|
||||
for _, w := range want {
|
||||
if !covered(names, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func covered(names []string, want string) bool {
|
||||
want = strings.ToLower(strings.TrimSuffix(want, "."))
|
||||
for _, n := range names {
|
||||
n = strings.ToLower(n)
|
||||
if n == want {
|
||||
return true
|
||||
}
|
||||
if base, ok := strings.CutPrefix(n, "*."); ok && !strings.HasPrefix(want, "*.") {
|
||||
if _, rest, ok := strings.Cut(want, "."); ok && rest == base {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// resolver asks recursive resolvers (acme.resolvers: public ones by
|
||||
// default), to see a domain as the CA does rather than as this server's
|
||||
// own views answer it.
|
||||
type resolver struct {
|
||||
servers []string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (r resolver) query(ctx context.Context, name string, qtype uint16) (*dns.Msg, error) {
|
||||
if len(r.servers) == 0 {
|
||||
return nil, errors.New("no resolvers configured (acme.resolvers)")
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(dns.Fqdn(name), qtype)
|
||||
m.SetEdns0(1232, false)
|
||||
m.RecursionDesired = true
|
||||
timeout := r.timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
var errs []error
|
||||
for _, s := range r.servers {
|
||||
c := &dns.Client{Timeout: timeout}
|
||||
in, _, err := c.ExchangeContext(ctx, m, s)
|
||||
if err == nil && in.Truncated {
|
||||
c.Net = "tcp"
|
||||
in, _, err = c.ExchangeContext(ctx, m, s)
|
||||
}
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", s, err))
|
||||
continue
|
||||
}
|
||||
if in.Rcode == dns.RcodeServerFailure || in.Rcode == dns.RcodeRefused {
|
||||
errs = append(errs, fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode]))
|
||||
continue
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], errors.Join(errs...))
|
||||
}
|
||||
|
||||
// zoneOf returns the zone that name is in on the internet (the owner of
|
||||
// the SOA record that answers for it).
|
||||
func (r resolver) zoneOf(ctx context.Context, name string) (string, error) {
|
||||
name = dns.Fqdn(strings.ToLower(name))
|
||||
for cand := name; ; {
|
||||
in, err := r.query(ctx, cand, dns.TypeSOA)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, sec := range [][]dns.RR{in.Answer, in.Ns} {
|
||||
for _, rr := range sec {
|
||||
if soa, ok := rr.(*dns.SOA); ok && dns.IsSubDomain(soa.Hdr.Name, name) {
|
||||
return strings.ToLower(soa.Hdr.Name), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
i, end := dns.NextLabel(cand, 0)
|
||||
if end {
|
||||
return "", fmt.Errorf("no zone found for %s", strings.TrimSuffix(name, "."))
|
||||
}
|
||||
cand = cand[i:]
|
||||
}
|
||||
}
|
||||
|
||||
// nameServers returns the zone's name servers on the internet (lowercase,
|
||||
// without trailing dots, sorted).
|
||||
func (r resolver) nameServers(ctx context.Context, zone string) ([]string, error) {
|
||||
in, err := r.query(ctx, zone, dns.TypeNS)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, rr := range in.Answer {
|
||||
if ns, ok := rr.(*dns.NS); ok && strings.EqualFold(ns.Hdr.Name, dns.Fqdn(zone)) {
|
||||
out = append(out, normName(ns.Ns))
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("%s has no name servers on the internet (%s)", strings.TrimSuffix(zone, "."), dns.RcodeToString[in.Rcode])
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out), nil
|
||||
}
|
||||
|
||||
// follow returns where name leads after its CNAME records (name itself
|
||||
// when it has none).
|
||||
func (r resolver) follow(ctx context.Context, name string) (string, error) {
|
||||
name = dns.Fqdn(strings.ToLower(name))
|
||||
for range 10 {
|
||||
in, err := r.query(ctx, name, dns.TypeCNAME)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
next := ""
|
||||
for _, rr := range in.Answer {
|
||||
if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(c.Hdr.Name, name) {
|
||||
next = strings.ToLower(c.Target)
|
||||
}
|
||||
}
|
||||
if next == "" {
|
||||
return name, nil
|
||||
}
|
||||
name = next
|
||||
}
|
||||
return "", fmt.Errorf("%s: more than 10 CNAME records in a row", name)
|
||||
}
|
||||
|
||||
func normName(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) }
|
||||
|
||||
// challengeName is the DNS-01 record name for a certificate name (a
|
||||
// wildcard shares its base name's).
|
||||
func challengeName(domain string) string {
|
||||
return "_acme-challenge." + dns.Fqdn(strings.TrimPrefix(strings.ToLower(domain), "*."))
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
legolog "github.com/go-acme/lego/v4/log"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/store"
|
||||
)
|
||||
|
||||
// Config configures the manager (acme and certificates in jarl.yaml).
|
||||
type Config struct {
|
||||
Directory string // ACME directory URL
|
||||
Email string
|
||||
AgreeTOS bool
|
||||
EABKeyID string
|
||||
EABHMAC string
|
||||
Resolvers []string
|
||||
CABundle string
|
||||
// Dir is where certificates with the dns use are written for named.
|
||||
Dir string
|
||||
Version string
|
||||
// Porkbun publishes challenges for domains Porkbun serves (nil
|
||||
// without an API key); PorkbunAllows limits it to porkbun.domains.
|
||||
Porkbun *porkbun.Client
|
||||
PorkbunAllows func(domain string) bool
|
||||
// CheckPropagation makes lego wait until every name server of the
|
||||
// zone serves the challenge before telling the CA (acme.check_propagation).
|
||||
CheckPropagation bool
|
||||
// PropagationTimeout is how long to wait for that (default 10 minutes).
|
||||
PropagationTimeout time.Duration
|
||||
// SkipPropagationCheck is !CheckPropagation (set by New).
|
||||
SkipPropagationCheck bool
|
||||
}
|
||||
|
||||
// Manager keeps certificates current and serves jarld's own.
|
||||
type Manager struct {
|
||||
cfg Config
|
||||
store *store.Store
|
||||
eng *engine.Engine
|
||||
log *slog.Logger
|
||||
res resolver
|
||||
|
||||
// legoMu: lego keeps its DNS settings in package variables, so one
|
||||
// client works at a time. issueMu: one issue at a time.
|
||||
legoMu sync.Mutex
|
||||
issueMu sync.Mutex
|
||||
|
||||
web atomic.Pointer[webCert]
|
||||
mu sync.Mutex
|
||||
files map[int64]fileStamp // file-source certificates: what was read last
|
||||
kick chan struct{}
|
||||
now func() time.Time
|
||||
// issuing holds the names being obtained now, with when it started.
|
||||
issuing sync.Map
|
||||
// base is the context background issues run in (jarld's lifetime).
|
||||
base context.Context
|
||||
}
|
||||
|
||||
type webCert struct {
|
||||
name string
|
||||
pair *tls.Certificate
|
||||
}
|
||||
|
||||
type fileStamp struct {
|
||||
certMod, keyMod time.Time
|
||||
certSize int64
|
||||
}
|
||||
|
||||
// New returns a manager. Call Start before serving, then Run.
|
||||
func New(cfg Config, st *store.Store, eng *engine.Engine, logger *slog.Logger) *Manager {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
cfg.SkipPropagationCheck = !cfg.CheckPropagation
|
||||
m := &Manager{cfg: cfg, store: st, eng: eng, log: logger, res: resolver{servers: cfg.Resolvers},
|
||||
files: map[int64]fileStamp{}, kick: make(chan struct{}, 1), now: time.Now, base: context.Background()}
|
||||
// lego logs every step; keep it at debug level.
|
||||
legolog.Logger = log.New(slogWriter{logger}, "", 0)
|
||||
return m
|
||||
}
|
||||
|
||||
type slogWriter struct{ l *slog.Logger }
|
||||
|
||||
func (w slogWriter) Write(p []byte) (int, error) {
|
||||
w.l.Debug("lego: " + strings.TrimSpace(string(p)))
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Errors for the API.
|
||||
var (
|
||||
ErrNotFound = errors.New("no such certificate")
|
||||
ErrConflict = errors.New("a certificate with that name exists")
|
||||
)
|
||||
|
||||
// InputError is a request Jarl refuses.
|
||||
type InputError struct{ Msg string }
|
||||
|
||||
func (e *InputError) Error() string { return e.Msg }
|
||||
|
||||
func inputErr(format string, a ...any) error { return &InputError{Msg: fmt.Sprintf(format, a...)} }
|
||||
|
||||
// Input creates or changes a certificate; nil fields are left alone.
|
||||
type Input struct {
|
||||
Name *string `json:"name"`
|
||||
Source *string `json:"source"`
|
||||
Domains *[]string `json:"domains"`
|
||||
CertFile *string `json:"cert_file"`
|
||||
KeyFile *string `json:"key_file"`
|
||||
Uses *[]string `json:"uses"`
|
||||
Challenge *string `json:"challenge"`
|
||||
KeyType *string `json:"key_type"`
|
||||
Comment *string `json:"comment"`
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
|
||||
var labelRe = regexp.MustCompile(`^[a-z0-9_]([a-z0-9_-]{0,61}[a-z0-9_])?$`)
|
||||
|
||||
func normDomains(in []string) ([]string, error) {
|
||||
var out []string
|
||||
for _, d := range in {
|
||||
d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), "."))
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
labels := strings.Split(d, ".")
|
||||
if len(labels) < 2 {
|
||||
return nil, inputErr("%q isn't a domain name a CA will sign (it needs at least two labels)", d)
|
||||
}
|
||||
for i, l := range labels {
|
||||
if l == "*" && i == 0 {
|
||||
continue
|
||||
}
|
||||
if !labelRe.MatchString(l) {
|
||||
return nil, inputErr("%q isn't a valid domain name (a wildcard is only allowed as the first label: *.example.com)", d)
|
||||
}
|
||||
}
|
||||
if len(d) > 253 {
|
||||
return nil, inputErr("%q is too long", d)
|
||||
}
|
||||
if !slices.Contains(out, d) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, inputErr("give at least one domain name")
|
||||
}
|
||||
if len(out) > 100 {
|
||||
return nil, inputErr("at most 100 names per certificate")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Manager) apply(c *model.Certificate, in Input, creating bool) error {
|
||||
if in.Name != nil {
|
||||
if !creating && *in.Name != c.Name {
|
||||
return inputErr("a certificate can't be renamed (its files for named are named after it): create a new one and delete this one")
|
||||
}
|
||||
c.Name = *in.Name
|
||||
}
|
||||
if !nameRe.MatchString(c.Name) {
|
||||
return inputErr("name %q: use lowercase letters, digits, - and _ (at most 63)", c.Name)
|
||||
}
|
||||
if in.Source != nil {
|
||||
if !creating && *in.Source != c.Source {
|
||||
return inputErr("a certificate's source can't change: create a new one")
|
||||
}
|
||||
c.Source = *in.Source
|
||||
}
|
||||
if c.Source == "" {
|
||||
c.Source = model.CertSourceACME
|
||||
}
|
||||
if in.Uses != nil {
|
||||
c.Uses = nil
|
||||
for _, u := range *in.Uses {
|
||||
u = strings.ToLower(strings.TrimSpace(u))
|
||||
if u != model.CertUseWeb && u != model.CertUseDNS {
|
||||
return inputErr("unknown use %q: web (jarld's HTTPS) or dns (for named: DNS over TLS and HTTPS)", u)
|
||||
}
|
||||
if !slices.Contains(c.Uses, u) {
|
||||
c.Uses = append(c.Uses, u)
|
||||
}
|
||||
}
|
||||
slices.Sort(c.Uses)
|
||||
}
|
||||
if c.Uses == nil {
|
||||
c.Uses = []string{}
|
||||
}
|
||||
if in.Comment != nil {
|
||||
c.Comment = *in.Comment
|
||||
}
|
||||
switch c.Source {
|
||||
case model.CertSourceACME:
|
||||
if in.CertFile != nil && *in.CertFile != "" || in.KeyFile != nil && *in.KeyFile != "" {
|
||||
return inputErr("cert_file and key_file are for --source file; Jarl keeps the files of certificates it obtains itself")
|
||||
}
|
||||
if in.Domains != nil {
|
||||
d, err := normDomains(*in.Domains)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.Domains = d
|
||||
}
|
||||
if len(c.Domains) == 0 {
|
||||
return inputErr("give the domain names the certificate is for")
|
||||
}
|
||||
if in.Challenge != nil {
|
||||
c.Challenge = *in.Challenge
|
||||
}
|
||||
switch c.Challenge {
|
||||
case "":
|
||||
c.Challenge = model.ChallengeAuto
|
||||
case model.ChallengeAuto, model.ChallengeJarl, model.ChallengePorkbun:
|
||||
default:
|
||||
return inputErr("challenge must be auto, jarl or porkbun")
|
||||
}
|
||||
if in.KeyType != nil {
|
||||
c.KeyType = *in.KeyType
|
||||
}
|
||||
if c.KeyType == "" {
|
||||
c.KeyType = "ec256"
|
||||
}
|
||||
if !slices.Contains(KeyTypes, c.KeyType) {
|
||||
return inputErr("key type must be one of %s", strings.Join(KeyTypes, ", "))
|
||||
}
|
||||
case model.CertSourceFile:
|
||||
if in.Challenge != nil && *in.Challenge != "" || in.KeyType != nil && *in.KeyType != "" {
|
||||
return inputErr("challenge and key type are for certificates Jarl obtains (--source acme)")
|
||||
}
|
||||
if in.CertFile != nil {
|
||||
c.CertFile = *in.CertFile
|
||||
}
|
||||
if in.KeyFile != nil {
|
||||
c.KeyFile = *in.KeyFile
|
||||
}
|
||||
if !filepath.IsAbs(c.CertFile) || !filepath.IsAbs(c.KeyFile) {
|
||||
return inputErr("cert_file and key_file must be absolute paths (the full chain, and its key)")
|
||||
}
|
||||
c.Challenge, c.KeyType = "", ""
|
||||
default:
|
||||
return inputErr("source must be acme (Jarl obtains and renews it) or file (you renew it)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkUses refuses a second certificate for jarld's HTTPS.
|
||||
func (m *Manager) checkUses(ctx context.Context, c *model.Certificate) error {
|
||||
if !c.HasUse(model.CertUseWeb) {
|
||||
return nil
|
||||
}
|
||||
all, err := m.store.Certificates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, o := range all {
|
||||
if o.ID != c.ID && o.HasUse(model.CertUseWeb) {
|
||||
return inputErr("certificate %s is already used for jarld's HTTPS; take the web use off it first (jarl cert update %s --use ...)", o.Name, o.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns every certificate.
|
||||
func (m *Manager) List(ctx context.Context) ([]*model.Certificate, error) {
|
||||
return m.store.Certificates(ctx)
|
||||
}
|
||||
|
||||
// Get returns one certificate.
|
||||
func (m *Manager) Get(ctx context.Context, name string) (*model.Certificate, error) {
|
||||
c, err := m.store.CertificateByName(ctx, name)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// Create adds a certificate. A file-source certificate is read at once;
|
||||
// one Jarl obtains is left for Issue (or the background loop).
|
||||
func (m *Manager) Create(ctx context.Context, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||
c := &model.Certificate{CreatedBy: p.Name(), UpdatedBy: p.Name()}
|
||||
if err := m.apply(c, in, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.checkUses(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Source == model.CertSourceFile {
|
||||
if _, err := m.readFiles(c); err != nil {
|
||||
return nil, inputErr("%v", err)
|
||||
}
|
||||
}
|
||||
if err := m.store.CreateCertificate(ctx, c); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return nil, ErrConflict
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
m.eng.Audit(ctx, p, "certificate.create", "certificate", c.ID, c.Name, "ok", describe(c))
|
||||
if c.Source == model.CertSourceFile {
|
||||
m.refreshFile(ctx, c, true)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Update changes a certificate. New names or a new key type make one Jarl
|
||||
// obtains due at once.
|
||||
func (m *Manager) Update(ctx context.Context, name string, in Input, p *auth.Principal) (*model.Certificate, error) {
|
||||
c, err := m.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before := *c
|
||||
if err := m.apply(c, in, false); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.checkUses(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Source == model.CertSourceFile && (c.CertFile != before.CertFile || c.KeyFile != before.KeyFile) {
|
||||
if _, err := m.readFiles(c); err != nil {
|
||||
return nil, inputErr("%v", err)
|
||||
}
|
||||
}
|
||||
if c.Source == model.CertSourceACME && c.CertPEM != "" && (!slices.Equal(c.Domains, before.Domains) || c.KeyType != before.KeyType) {
|
||||
now := m.now().UTC()
|
||||
c.RenewAt, c.Failures = &now, 0
|
||||
}
|
||||
c.UpdatedBy = p.Name()
|
||||
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.eng.Audit(ctx, p, "certificate.update", "certificate", c.ID, c.Name, "ok", describe(c))
|
||||
if c.Source == model.CertSourceFile {
|
||||
m.mu.Lock()
|
||||
delete(m.files, c.ID)
|
||||
m.mu.Unlock()
|
||||
m.refreshFile(ctx, c, true)
|
||||
} else if c.CertPEM != "" {
|
||||
if err := m.deploy(ctx, c, []byte(c.CertPEM), []byte(c.KeyPEM)); err != nil {
|
||||
m.log.Warn("writing certificate files failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
}
|
||||
if before.HasUse(model.CertUseWeb) && !c.HasUse(model.CertUseWeb) {
|
||||
m.dropWeb(c.Name)
|
||||
}
|
||||
m.Kick()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Delete removes a certificate and its files for named.
|
||||
func (m *Manager) Delete(ctx context.Context, name string, p *auth.Principal) error {
|
||||
c, err := m.Get(ctx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := m.store.DeleteCertificate(ctx, c.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
m.dropWeb(c.Name)
|
||||
if err := m.removeFiles(c.Name); err != nil {
|
||||
m.log.Warn("removing certificate files failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
delete(m.files, c.ID)
|
||||
m.mu.Unlock()
|
||||
m.eng.Audit(ctx, p, "certificate.delete", "certificate", c.ID, c.Name, "ok", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func describe(c *model.Certificate) string {
|
||||
s := c.Source
|
||||
if c.Source == model.CertSourceACME {
|
||||
s += " " + strings.Join(c.Domains, " ") + ", challenge " + c.Challenge
|
||||
} else {
|
||||
s += " " + c.CertFile
|
||||
}
|
||||
if len(c.Uses) > 0 {
|
||||
s += ", uses " + strings.Join(c.Uses, ",")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Preview checks a new certificate's fields without saving it.
|
||||
func (m *Manager) Preview(in Input) (*model.Certificate, error) {
|
||||
c := &model.Certificate{}
|
||||
if err := m.apply(c, in, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Check works out where each name's challenge would go, without asking
|
||||
// the CA for anything.
|
||||
func (m *Manager) Check(ctx context.Context, c *model.Certificate) ([]Route, error) {
|
||||
if c.Source != model.CertSourceACME {
|
||||
return nil, inputErr("certificate %s is renewed by you (source file); there is nothing to check", c.Name)
|
||||
}
|
||||
st, err := m.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Route
|
||||
for _, d := range c.Domains {
|
||||
out = append(out, m.route(ctx, st, c.Challenge, d, ""))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IssueResult is the outcome of an issue.
|
||||
type IssueResult struct {
|
||||
Certificate *model.Certificate `json:"certificate"`
|
||||
Routes []Route `json:"routes"`
|
||||
}
|
||||
|
||||
// Issuing reports since when the certificate is being obtained (zero when
|
||||
// it isn't).
|
||||
func (m *Manager) Issuing(name string) time.Time {
|
||||
if v, ok := m.issuing.Load(name); ok {
|
||||
return v.(time.Time)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// IssueAsync starts obtaining a certificate in the background and returns
|
||||
// at once: talking to the CA and waiting for every name server to serve
|
||||
// the challenge can take minutes. Issuing tells when it is done; the
|
||||
// outcome is in the certificate (last_success, last_error).
|
||||
func (m *Manager) IssueAsync(ctx context.Context, name string, p *auth.Principal) (*model.Certificate, error) {
|
||||
c, err := m.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Source == model.CertSourceFile {
|
||||
res, err := m.Issue(ctx, name, p)
|
||||
if res != nil {
|
||||
return res.Certificate, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if _, busy := m.issuing.LoadOrStore(name, m.now()); busy {
|
||||
return c, nil
|
||||
}
|
||||
go func() {
|
||||
defer m.issuing.Delete(name)
|
||||
m.issue(m.base, c, p)
|
||||
}()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Issue obtains a new certificate for c now (a file-source certificate is
|
||||
// reread instead).
|
||||
func (m *Manager) Issue(ctx context.Context, name string, p *auth.Principal) (*IssueResult, error) {
|
||||
c, err := m.Get(ctx, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.Source == model.CertSourceFile {
|
||||
m.mu.Lock()
|
||||
delete(m.files, c.ID)
|
||||
m.mu.Unlock()
|
||||
if err := m.refreshFile(ctx, c, true); err != nil {
|
||||
return &IssueResult{Certificate: c}, err
|
||||
}
|
||||
return &IssueResult{Certificate: c}, nil
|
||||
}
|
||||
return m.issue(ctx, c, p)
|
||||
}
|
||||
|
||||
func (m *Manager) issue(ctx context.Context, c *model.Certificate, p *auth.Principal) (*IssueResult, error) {
|
||||
m.issueMu.Lock()
|
||||
defer m.issueMu.Unlock()
|
||||
// Reload: another issue may have finished while this one waited.
|
||||
if fresh, err := m.Get(ctx, c.Name); err == nil {
|
||||
c = fresh
|
||||
}
|
||||
var old *x509.Certificate
|
||||
if c.CertPEM != "" {
|
||||
if in, _, err := Inspect([]byte(c.CertPEM), []byte(c.KeyPEM)); err == nil {
|
||||
old = in.Leaf
|
||||
}
|
||||
}
|
||||
m.log.Info("obtaining certificate", "certificate", c.Name, "domains", strings.Join(c.Domains, " "), "by", p.Name())
|
||||
now := m.now().UTC()
|
||||
c.LastAttempt = &now
|
||||
res, routes, err := m.obtain(ctx, c, old)
|
||||
c.LastRoutes = nil
|
||||
for _, r := range routes {
|
||||
if r.Error == "" {
|
||||
c.LastRoutes = append(c.LastRoutes, r.String())
|
||||
}
|
||||
}
|
||||
var info *Info
|
||||
if err == nil {
|
||||
info, _, err = Inspect(res.Certificate, res.PrivateKey)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("the CA's certificate is unusable: %w", err)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
c.Failures++
|
||||
c.LastError = err.Error()
|
||||
if serr := m.store.UpdateCertificate(ctx, c); serr != nil {
|
||||
m.log.Error("saving certificate state failed", "certificate", c.Name, "err", serr)
|
||||
}
|
||||
m.log.Warn("obtaining certificate failed", "certificate", c.Name, "failures", c.Failures, "err", err)
|
||||
m.eng.Audit(ctx, p, "certificate.issue", "certificate", c.ID, c.Name, "error", err.Error())
|
||||
return &IssueResult{Certificate: c, Routes: routes}, err
|
||||
}
|
||||
c.CertPEM, c.KeyPEM = string(res.Certificate), string(res.PrivateKey)
|
||||
setInfo(c, info)
|
||||
done := m.now().UTC()
|
||||
c.LastSuccess, c.Failures, c.LastError = &done, 0, ""
|
||||
at := twoThirds(info.NotBefore, info.NotAfter)
|
||||
c.RenewAt, c.ARICheckedAt = &at, nil
|
||||
if t, err := m.renewalInfo(ctx, c, info.Leaf); err == nil && t != nil {
|
||||
c.RenewAt, c.ARICheckedAt = t, &done
|
||||
}
|
||||
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.log.Info("certificate obtained", "certificate", c.Name, "expires", info.NotAfter.Format(time.DateOnly), "renew_at", c.RenewAt.Format(time.RFC3339), "routes", strings.Join(c.LastRoutes, "; "))
|
||||
m.eng.Audit(ctx, p, "certificate.issue", "certificate", c.ID, c.Name, "ok", fmt.Sprintf("%s, valid until %s, from %s", strings.Join(info.Names, " "), info.NotAfter.Format(time.DateOnly), info.Issuer))
|
||||
if err := m.deploy(ctx, c, res.Certificate, res.PrivateKey); err != nil {
|
||||
m.log.Error("writing certificate files failed", "certificate", c.Name, "err", err)
|
||||
return &IssueResult{Certificate: c, Routes: routes}, fmt.Errorf("the certificate was obtained, but writing it for named failed: %w", err)
|
||||
}
|
||||
return &IssueResult{Certificate: c, Routes: routes}, nil
|
||||
}
|
||||
|
||||
func setInfo(c *model.Certificate, in *Info) {
|
||||
nb, na := in.NotBefore.UTC(), in.NotAfter.UTC()
|
||||
c.Names, c.NotBefore, c.NotAfter, c.Issuer, c.Serial = in.Names, &nb, &na, in.Issuer, in.Serial
|
||||
}
|
||||
|
||||
// readFiles reads a file-source certificate and checks it.
|
||||
func (m *Manager) readFiles(c *model.Certificate) (*Info, error) {
|
||||
cert, err := os.ReadFile(c.CertFile)
|
||||
if err != nil {
|
||||
return nil, fileErr(err)
|
||||
}
|
||||
key, err := os.ReadFile(c.KeyFile)
|
||||
if err != nil {
|
||||
return nil, fileErr(err)
|
||||
}
|
||||
in, _, err := Inspect(cert, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s and %s: %w", c.CertFile, c.KeyFile, err)
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func fileErr(err error) error {
|
||||
if errors.Is(err, fs.ErrPermission) {
|
||||
return fmt.Errorf("%w: jarld runs as its own user (jarl) and must be able to read the certificate and its key; e.g. give the files group jarl (chgrp jarl FILE; chmod 640 FILE), or copy them somewhere it can read in your renewal hook", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// refreshFile rereads a file-source certificate when its files changed
|
||||
// (always with force) and deploys it.
|
||||
func (m *Manager) refreshFile(ctx context.Context, c *model.Certificate, force bool) error {
|
||||
cs, err1 := os.Stat(c.CertFile)
|
||||
ks, err2 := os.Stat(c.KeyFile)
|
||||
var stamp fileStamp
|
||||
if err1 == nil && err2 == nil {
|
||||
stamp = fileStamp{certMod: cs.ModTime(), keyMod: ks.ModTime(), certSize: cs.Size()}
|
||||
m.mu.Lock()
|
||||
prev, ok := m.files[c.ID]
|
||||
m.mu.Unlock()
|
||||
if ok && prev == stamp && !force {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
in, err := m.readFiles(c)
|
||||
now := m.now().UTC()
|
||||
c.LastAttempt = &now
|
||||
if err != nil {
|
||||
if c.LastError != err.Error() {
|
||||
m.log.Warn("reading certificate failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
c.LastError = err.Error()
|
||||
c.Failures = max(c.Failures, 1)
|
||||
m.store.UpdateCertificate(ctx, c)
|
||||
return err
|
||||
}
|
||||
if c.Serial != in.Serial {
|
||||
m.log.Info("certificate read", "certificate", c.Name, "file", c.CertFile, "expires", in.NotAfter.Format(time.DateOnly))
|
||||
}
|
||||
setInfo(c, in)
|
||||
c.Domains = in.Names
|
||||
c.LastSuccess, c.LastError, c.Failures = &now, "", 0
|
||||
if len(in.Warnings) > 0 {
|
||||
c.LastError = in.Warnings[0]
|
||||
}
|
||||
if err := m.store.UpdateCertificate(ctx, c); err != nil {
|
||||
return err
|
||||
}
|
||||
cert, _ := os.ReadFile(c.CertFile)
|
||||
key, _ := os.ReadFile(c.KeyFile)
|
||||
if err := m.deploy(ctx, c, cert, key); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.files[c.ID] = stamp
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilesDir is where a certificate with the dns use is written for named.
|
||||
func (m *Manager) FilesDir(name string) string { return filepath.Join(m.cfg.Dir, name) }
|
||||
|
||||
// Files names a certificate's files for named.
|
||||
const (
|
||||
ChainFile = "fullchain.pem"
|
||||
KeyFile = "privkey.pem"
|
||||
)
|
||||
|
||||
// deploy puts a certificate where it is used: jarld's HTTPS and/or the
|
||||
// files named reads. When those files change and named's configuration
|
||||
// names them, named rereads it.
|
||||
func (m *Manager) deploy(ctx context.Context, c *model.Certificate, cert, key []byte) error {
|
||||
_, pair, err := Inspect(cert, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.HasUse(model.CertUseWeb) {
|
||||
m.web.Store(&webCert{name: c.Name, pair: pair})
|
||||
} else {
|
||||
m.dropWeb(c.Name)
|
||||
}
|
||||
if !c.HasUse(model.CertUseDNS) {
|
||||
return m.removeFiles(c.Name)
|
||||
}
|
||||
dir := m.FilesDir(c.Name)
|
||||
changed := false
|
||||
for _, f := range []struct {
|
||||
name string
|
||||
content []byte
|
||||
mode fs.FileMode
|
||||
}{{ChainFile, cert, 0o644}, {KeyFile, key, 0o640}} {
|
||||
path := filepath.Join(dir, f.name)
|
||||
if old, err := os.ReadFile(path); err == nil && bytes.Equal(old, f.content) {
|
||||
continue
|
||||
}
|
||||
if err := m.eng.WriteFile(path, f.content, f.mode); err != nil {
|
||||
return err
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed && m.namedUses(ctx, dir) {
|
||||
if err := m.eng.Reconfig(ctx); err != nil {
|
||||
return fmt.Errorf("named didn't reread its configuration for the new certificate: %w", err)
|
||||
}
|
||||
m.log.Info("named rereads the new certificate", "certificate", c.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// namedUses reports whether named's configuration names files in dir.
|
||||
func (m *Manager) namedUses(ctx context.Context, dir string) bool {
|
||||
st, err := m.store.LoadState(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, o := range st.Options {
|
||||
if strings.Contains(o.Value, dir+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) removeFiles(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, "/.") {
|
||||
return nil
|
||||
}
|
||||
dir := m.FilesDir(name)
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
return nil
|
||||
}
|
||||
return os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
func (m *Manager) dropWeb(name string) {
|
||||
if w := m.web.Load(); w != nil && w.name == name {
|
||||
m.web.CompareAndSwap(w, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// WebCertificate is the certificate for jarld's HTTPS (nil when no
|
||||
// certificate has the web use, or it has none yet).
|
||||
func (m *Manager) WebCertificate() *tls.Certificate {
|
||||
if w := m.web.Load(); w != nil {
|
||||
return w.pair
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WebName is the name of the certificate jarld serves ("" for none).
|
||||
func (m *Manager) WebName() string {
|
||||
if w := m.web.Load(); w != nil {
|
||||
return w.name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HasWeb reports whether a certificate has the web use (whether or not
|
||||
// it has been obtained yet).
|
||||
func (m *Manager) HasWeb(ctx context.Context) bool {
|
||||
all, err := m.store.Certificates(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return slices.ContainsFunc(all, func(c *model.Certificate) bool { return c.HasUse(model.CertUseWeb) })
|
||||
}
|
||||
|
||||
// Kick makes the loop look at the certificates now.
|
||||
func (m *Manager) Kick() {
|
||||
select {
|
||||
case m.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Start loads the certificates and puts them in place (jarld's HTTPS, the
|
||||
// files for named). Call it before serving.
|
||||
func (m *Manager) Start(ctx context.Context) {
|
||||
all, err := m.store.Certificates(ctx)
|
||||
if err != nil {
|
||||
m.log.Error("loading certificates failed", "err", err)
|
||||
return
|
||||
}
|
||||
for _, c := range all {
|
||||
switch {
|
||||
case c.Source == model.CertSourceFile:
|
||||
m.refreshFile(ctx, c, true)
|
||||
case c.CertPEM != "":
|
||||
if err := m.deploy(ctx, c, []byte(c.CertPEM), []byte(c.KeyPEM)); err != nil {
|
||||
m.log.Warn("putting certificate in place failed", "certificate", c.Name, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run renews certificates when they are due and rereads file-source ones
|
||||
// when their files change, until ctx ends.
|
||||
func (m *Manager) Run(ctx context.Context) {
|
||||
m.base = ctx
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(20 * time.Second): // let jarld finish starting
|
||||
}
|
||||
for {
|
||||
m.Tick(ctx)
|
||||
t := time.NewTimer(10 * time.Minute)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
case <-t.C:
|
||||
case <-m.kick:
|
||||
t.Stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tick does one round of the loop.
|
||||
func (m *Manager) Tick(ctx context.Context) {
|
||||
all, err := m.store.Certificates(ctx)
|
||||
if err != nil {
|
||||
m.log.Error("loading certificates failed", "err", err)
|
||||
return
|
||||
}
|
||||
now := m.now()
|
||||
for _, c := range all {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if c.Source == model.CertSourceFile {
|
||||
m.refreshFile(ctx, c, false)
|
||||
continue
|
||||
}
|
||||
if at := m.Due(c); !at.After(now) {
|
||||
if _, busy := m.issuing.LoadOrStore(c.Name, now); !busy {
|
||||
m.issue(ctx, c, auth.System)
|
||||
m.issuing.Delete(c.Name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Ask the CA about the renewal window twice a day (it moves it
|
||||
// earlier when a certificate must be replaced, e.g. revoked).
|
||||
if c.CertPEM != "" && (c.ARICheckedAt == nil || now.Sub(*c.ARICheckedAt) > 12*time.Hour) {
|
||||
in, _, err := Inspect([]byte(c.CertPEM), []byte(c.KeyPEM))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
t, err := m.renewalInfo(ctx, c, in.Leaf)
|
||||
checked := now.UTC()
|
||||
c.ARICheckedAt = &checked
|
||||
if err == nil && t != nil && (c.RenewAt == nil || !t.Equal(*c.RenewAt)) {
|
||||
m.log.Info("renewal time from the CA", "certificate", c.Name, "renew_at", t.Format(time.RFC3339))
|
||||
c.RenewAt = t
|
||||
}
|
||||
m.store.UpdateCertificate(ctx, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Due is when c should be obtained (again): at once when it has no
|
||||
// certificate yet or its names changed; else at its renewal time. After
|
||||
// failures it waits 1, 2, 4 ... hours (at most a day) between attempts.
|
||||
func (m *Manager) Due(c *model.Certificate) time.Time {
|
||||
var at time.Time
|
||||
switch {
|
||||
case c.CertPEM == "":
|
||||
at = c.CreatedAt
|
||||
case c.RenewAt != nil:
|
||||
at = *c.RenewAt
|
||||
case c.NotBefore != nil && c.NotAfter != nil:
|
||||
at = twoThirds(*c.NotBefore, *c.NotAfter)
|
||||
}
|
||||
if c.Failures > 0 && c.LastAttempt != nil {
|
||||
wait := time.Hour << min(c.Failures-1, 5)
|
||||
at = maxTime(at, c.LastAttempt.Add(min(wait, 24*time.Hour)))
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
func maxTime(a, b time.Time) time.Time {
|
||||
if a.After(b) {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Status is a certificate's health.
|
||||
type Status struct {
|
||||
State string `json:"state"` // ok, pending, failing, expiring, expired
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// StatusOf says how c is doing.
|
||||
func (m *Manager) StatusOf(c *model.Certificate) Status {
|
||||
now := m.now()
|
||||
if c.NotAfter == nil {
|
||||
if c.Failures > 0 {
|
||||
return Status{"failing", "not obtained yet: " + c.LastError}
|
||||
}
|
||||
return Status{"pending", "not obtained yet"}
|
||||
}
|
||||
left := c.NotAfter.Sub(now)
|
||||
life := c.NotAfter.Sub(*c.NotBefore)
|
||||
switch {
|
||||
case left <= 0:
|
||||
return Status{"expired", "expired on " + c.NotAfter.Format(time.DateOnly)}
|
||||
case c.Failures > 0 && c.Source == model.CertSourceACME:
|
||||
return Status{"failing", fmt.Sprintf("renewing failed %d times (next try %s): %s", c.Failures, m.Due(c).Local().Format("2 Jan 15:04"), c.LastError)}
|
||||
case c.Failures > 0:
|
||||
return Status{"failing", c.LastError}
|
||||
case left < life/6 || (c.Source == model.CertSourceFile && left < 14*24*time.Hour):
|
||||
return Status{"expiring", fmt.Sprintf("expires in %s (%s)", days(left), c.NotAfter.Format(time.DateOnly))}
|
||||
}
|
||||
return Status{"ok", ""}
|
||||
}
|
||||
|
||||
func days(d time.Duration) string {
|
||||
n := int(d.Hours() / 24)
|
||||
if n == 1 {
|
||||
return "1 day"
|
||||
}
|
||||
if n < 1 {
|
||||
return fmt.Sprintf("%d hours", int(d.Hours()))
|
||||
}
|
||||
return fmt.Sprintf("%d days", n)
|
||||
}
|
||||
|
||||
// AccountInfo is the ACME account as the API shows it.
|
||||
type AccountInfo struct {
|
||||
Directory string `json:"directory"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AgreeTOS bool `json:"agree_tos"`
|
||||
EAB bool `json:"eab"`
|
||||
Registered bool `json:"registered"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
RegisteredAt *time.Time `json:"registered_at,omitempty"`
|
||||
Porkbun bool `json:"porkbun"`
|
||||
Resolvers []string `json:"resolvers"`
|
||||
Dir string `json:"dir"`
|
||||
ActiveWebCert string `json:"active_web_certificate,omitempty"`
|
||||
}
|
||||
|
||||
// AccountInfo describes the ACME account and settings.
|
||||
func (m *Manager) AccountInfo(ctx context.Context) AccountInfo {
|
||||
ai := AccountInfo{Directory: m.cfg.Directory, Email: m.cfg.Email, AgreeTOS: m.cfg.AgreeTOS, EAB: m.cfg.EABKeyID != "",
|
||||
Porkbun: m.cfg.Porkbun != nil, Resolvers: m.cfg.Resolvers, Dir: m.cfg.Dir, ActiveWebCert: m.WebName()}
|
||||
if a := m.account(ctx); a != nil && a.Directory == m.cfg.Directory && a.URI != "" {
|
||||
ai.Registered, ai.URI = true, a.URI
|
||||
t := a.RegisteredAt
|
||||
ai.RegisteredAt = &t
|
||||
}
|
||||
return ai
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package certs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"jarl/internal/acme"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// Route says where the DNS-01 challenge for one certificate name goes,
|
||||
// and why.
|
||||
type Route struct {
|
||||
Domain string `json:"domain"` // the name on the certificate
|
||||
Record string `json:"record"` // _acme-challenge.<domain>
|
||||
// Target is where the TXT record is written: Record, or where its
|
||||
// CNAME points.
|
||||
Target string `json:"target"`
|
||||
Route string `json:"route,omitempty"` // jarl or porkbun; "" when there is none
|
||||
Zone string `json:"zone,omitempty"` // the zone on the internet that holds Target
|
||||
PublicNS []string `json:"public_ns,omitempty"`
|
||||
Why string `json:"why"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func (r Route) String() string {
|
||||
s := r.Domain + ": " + r.Route
|
||||
if r.Target != r.Record {
|
||||
s += " via CNAME " + strings.TrimSuffix(r.Target, ".")
|
||||
}
|
||||
return s + " (zone " + strings.TrimSuffix(r.Zone, ".") + ")"
|
||||
}
|
||||
|
||||
func isPorkbunNS(ns []string) bool {
|
||||
for _, n := range ns {
|
||||
if strings.HasSuffix(n, ".porkbun.com") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// route decides where the challenge for domain goes. challenge is the
|
||||
// certificate's setting: auto picks from the zone's public name servers.
|
||||
// target is the record name after CNAMEs ("" to look it up).
|
||||
func (m *Manager) route(ctx context.Context, st *model.State, challenge, domain, target string) Route {
|
||||
r := Route{Domain: domain, Record: challengeName(domain)}
|
||||
fail := func(format string, a ...any) Route {
|
||||
r.Error = fmt.Sprintf(format, a...)
|
||||
return r
|
||||
}
|
||||
if target == "" {
|
||||
t, err := m.res.follow(ctx, r.Record)
|
||||
if err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
target = t
|
||||
}
|
||||
r.Target = target
|
||||
zone, err := m.res.zoneOf(ctx, target)
|
||||
if err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
r.Zone = zone
|
||||
ns, err := m.res.nameServers(ctx, zone)
|
||||
if err != nil {
|
||||
return fail("%v", err)
|
||||
}
|
||||
r.PublicNS = ns
|
||||
zoneName, nsList := strings.TrimSuffix(zone, "."), strings.Join(ns, ", ")
|
||||
|
||||
// Jarl can answer when it has the zone and the internet asks the name
|
||||
// servers listed in it.
|
||||
var jarlNS []string
|
||||
jarlHas := false
|
||||
for _, z := range st.SortedZones() {
|
||||
if z.Type != model.ZonePrimary || z.Disabled || normName(z.Name) != normName(zone) {
|
||||
continue
|
||||
}
|
||||
jarlHas = true
|
||||
for _, rec := range st.ZoneRecords(z.ID) {
|
||||
if rec.Type == "NS" && !rec.Disabled && normName(rec.Name) == normName(zone) {
|
||||
jarlNS = append(jarlNS, normName(rec.Data))
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(jarlNS)
|
||||
jarlNS = slices.Compact(jarlNS)
|
||||
servedByJarl := jarlHas && slices.ContainsFunc(ns, func(n string) bool { return slices.Contains(jarlNS, n) })
|
||||
porkbunOK := m.cfg.Porkbun != nil && (m.cfg.PorkbunAllows == nil || m.cfg.PorkbunAllows(zoneName))
|
||||
|
||||
porkbun := func() Route {
|
||||
r.Route, r.Why = model.ChallengePorkbun, fmt.Sprintf("%s is served by Porkbun (%s), so the record goes in through Porkbun's API", zoneName, nsList)
|
||||
return r
|
||||
}
|
||||
jarl := func() Route {
|
||||
r.Route, r.Why = model.ChallengeJarl, fmt.Sprintf("%s is served by this server's name servers (%s), so Jarl writes the record itself", zoneName, nsList)
|
||||
return r
|
||||
}
|
||||
switch challenge {
|
||||
case model.ChallengePorkbun:
|
||||
switch {
|
||||
case m.cfg.Porkbun == nil:
|
||||
return fail("the challenge is set to porkbun, but jarl.yaml has no porkbun.api_key")
|
||||
case !porkbunOK:
|
||||
return fail("the challenge is set to porkbun, but %s isn't in porkbun.domains in jarl.yaml", zoneName)
|
||||
case !isPorkbunNS(ns):
|
||||
return fail("the challenge is set to porkbun, but on the internet %s is served by %s, not Porkbun: the CA would never see the record (set the challenge to auto)", zoneName, nsList)
|
||||
}
|
||||
return porkbun()
|
||||
case model.ChallengeJarl:
|
||||
switch {
|
||||
case !jarlHas:
|
||||
return fail("the challenge is set to jarl, but no primary zone in Jarl is %s (where %s is on the internet)", zoneName, strings.TrimSuffix(target, "."))
|
||||
case !servedByJarl:
|
||||
return fail("the challenge is set to jarl, but on the internet %s is served by %s, not by the name servers in Jarl's zone (%s): the CA would never see the record (set the challenge to auto)", zoneName, nsList, strings.Join(jarlNS, ", "))
|
||||
}
|
||||
return jarl()
|
||||
}
|
||||
switch {
|
||||
case isPorkbunNS(ns) && porkbunOK:
|
||||
return porkbun()
|
||||
case servedByJarl:
|
||||
return jarl()
|
||||
case isPorkbunNS(ns) && m.cfg.Porkbun == nil:
|
||||
return fail("%s is served by Porkbun (%s) and jarl.yaml has no porkbun.api_key. Add the key, or point %s with a CNAME at a name in a zone this server serves on the internet", zoneName, nsList, strings.TrimSuffix(r.Record, "."))
|
||||
case isPorkbunNS(ns):
|
||||
return fail("%s is served by Porkbun (%s) but isn't in porkbun.domains in jarl.yaml", zoneName, nsList)
|
||||
case jarlHas:
|
||||
return fail("on the internet %s is served by %s, not by the name servers in Jarl's zone (%s), so the CA would never see a record Jarl writes. Delegate the zone to this server, or point %s with a CNAME at a name in a zone it serves", zoneName, nsList, strings.Join(jarlNS, ", "), strings.TrimSuffix(r.Record, "."))
|
||||
}
|
||||
return fail("%s is served by %s, which Jarl can't write to. Point %s with a CNAME at a name in a zone this server serves on the internet (for example %s.acme.<your domain>, in a zone acme.<your domain> delegated here): Jarl follows the CNAME and answers there", zoneName, nsList, strings.TrimSuffix(r.Record, "."), strings.ReplaceAll(strings.TrimSuffix(strings.TrimPrefix(r.Record, "_acme-challenge."), "."), ".", "-"))
|
||||
}
|
||||
|
||||
// primaryZones lists the zones Jarl can write challenge records into.
|
||||
func primaryZones(st *model.State) []acme.Zone {
|
||||
var out []acme.Zone
|
||||
for _, z := range st.SortedZones() {
|
||||
if z.Type == model.ZonePrimary && !z.Disabled {
|
||||
out = append(out, acme.Zone{Name: z.Name, View: st.ViewName(z)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user