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>
924 lines
27 KiB
Go
924 lines
27 KiB
Go
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
|
|
}
|