jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.
- Certificates have an owner user; another user can't take the name.
An existing one is obtained again only when a name is added or
removed. A new certificates grant (names=) says which names a user
may have; it is never part of "*". Users may own
certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
delete theirs; keys only over HTTPS, the socket or loopback; every
download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1095 lines
33 KiB
Go
1095 lines
33 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/encdns"
|
|
"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
|
|
// MaxPerUser is how many certificates a user may own (Ensure), unless
|
|
// the user has a limit of their own.
|
|
MaxPerUser int
|
|
}
|
|
|
|
// 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"`
|
|
// Owner sets the owning user (Ensure only).
|
|
Owner *int64 `json:"-"`
|
|
}
|
|
|
|
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
|
|
}
|
|
if in.Owner != nil {
|
|
c.OwnerID = *in.Owner
|
|
}
|
|
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
|
|
}
|
|
|
|
// PEM returns a certificate's current chain and private key, for
|
|
// exporting it: from Jarl's store, or from the files of one you renew
|
|
// yourself.
|
|
func (m *Manager) PEM(ctx context.Context, name string) (*model.Certificate, []byte, []byte, error) {
|
|
c, err := m.Get(ctx, name)
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
if c.Source == model.CertSourceFile {
|
|
cert, err := os.ReadFile(c.CertFile)
|
|
if err != nil {
|
|
return nil, nil, nil, fileErr(err)
|
|
}
|
|
key, err := os.ReadFile(c.KeyFile)
|
|
if err != nil {
|
|
return nil, nil, nil, fileErr(err)
|
|
}
|
|
return c, cert, key, nil
|
|
}
|
|
if c.CertPEM == "" {
|
|
return nil, nil, nil, &InputError{Msg: "certificate " + name + " hasn't been obtained yet, so there is nothing to export"}
|
|
}
|
|
return c, []byte(c.CertPEM), []byte(c.KeyPEM), nil
|
|
}
|
|
|
|
// ErrTaken is Ensure's answer for a name another user's certificate (or
|
|
// the administrators') has.
|
|
var ErrTaken = errors.New("certificate name taken")
|
|
|
|
// LimitFor is how many certificates u may own.
|
|
func (m *Manager) LimitFor(u *model.User) int {
|
|
if u != nil && u.CertLimit != nil {
|
|
return *u.CertLimit
|
|
}
|
|
return m.cfg.MaxPerUser
|
|
}
|
|
|
|
// EnsureResult is what Ensure did.
|
|
type EnsureResult struct {
|
|
Certificate *model.Certificate
|
|
Created bool // a new certificate
|
|
Changed bool // its names changed, so it is obtained again
|
|
// Ignored says what of the request an existing certificate keeps as
|
|
// it is (a different key type).
|
|
Ignored string
|
|
}
|
|
|
|
// Ensure is jarl cert get: the certificate name, owned by p's user, for
|
|
// domains. A new one is created (within the user's limit) and obtained
|
|
// at once. An existing one must be the user's: it is obtained again only
|
|
// when its names change (a name added or removed); otherwise it is
|
|
// returned as it is, renewed by the loop as usual. The caller checks that
|
|
// p may have these names.
|
|
func (m *Manager) Ensure(ctx context.Context, name string, domains []string, keyType string, p *auth.Principal) (*EnsureResult, error) {
|
|
owner := p.User
|
|
want, err := normDomains(domains)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c, err := m.Get(ctx, name)
|
|
switch {
|
|
case errors.Is(err, ErrNotFound):
|
|
all, err := m.List(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
n := 0
|
|
for _, o := range all {
|
|
if o.OwnerID == owner.ID {
|
|
n++
|
|
}
|
|
}
|
|
if limit := m.LimitFor(owner); n >= limit {
|
|
return nil, inputErr("user %s has %d certificates, its limit (an administrator can raise it: jarl user update %s --cert-limit N, or certificates.max_per_user in jarl.yaml)", owner.Username, n, owner.Username)
|
|
}
|
|
in := Input{Name: &name, Domains: &want, Owner: &owner.ID, Comment: model.Ptr("for " + owner.Username + " (jarl cert get)")}
|
|
if keyType != "" {
|
|
in.KeyType = &keyType
|
|
}
|
|
c, err = m.Create(ctx, in, p)
|
|
if errors.Is(err, ErrConflict) {
|
|
return nil, ErrTaken // created meanwhile
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
|
return nil, err
|
|
}
|
|
c, err = m.Get(ctx, name)
|
|
return &EnsureResult{Certificate: c, Created: true}, err
|
|
case err != nil:
|
|
return nil, err
|
|
}
|
|
if c.OwnerID != owner.ID && !(p.IsAdmin() && c.OwnerID == 0) {
|
|
return nil, ErrTaken
|
|
}
|
|
if c.Source != model.CertSourceACME {
|
|
return nil, inputErr("certificate %s is read from files on the server, so Jarl can't obtain it for you", name)
|
|
}
|
|
res := &EnsureResult{Certificate: c}
|
|
if keyType != "" && keyType != c.KeyType {
|
|
res.Ignored = "it keeps its key type " + c.KeyType + " (changing it means a new certificate: delete this one, or ask an administrator)"
|
|
}
|
|
have := slices.Clone(c.Domains)
|
|
slices.Sort(have)
|
|
sorted := slices.Clone(want)
|
|
slices.Sort(sorted)
|
|
if !slices.Equal(have, sorted) {
|
|
if c, err = m.Update(ctx, name, Input{Domains: &want}, p); err != nil {
|
|
return nil, err
|
|
}
|
|
res.Certificate, res.Changed = c, true
|
|
}
|
|
if res.Changed || c.CertPEM == "" && m.Issuing(name).IsZero() && c.Failures == 0 {
|
|
if _, err := m.IssueAsync(ctx, name, p); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// 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 before.HasUse(model.CertUseDNS) && !c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
|
return nil, inputErr("named's configuration uses certificate %s (a tls statement names its files), so it keeps the dns use: take it out of named first (jarl encrypted-dns disable)", c.Name)
|
|
}
|
|
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 c.HasUse(model.CertUseDNS) && m.namedUses(ctx, m.FilesDir(c.Name)) {
|
|
return inputErr("named's configuration uses certificate %s (a tls statement names its files): take it out first (jarl encrypted-dns disable, or remove the tls statement)", c.Name)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
m.followDir(ctx, all)
|
|
}
|
|
|
|
// followDir points the tls statements encrypted DNS wrote (jarl-NAME) at
|
|
// certificates.dir when it has changed: Start has just written the
|
|
// certificates there, and named would otherwise keep serving the old
|
|
// copies, which renewals no longer update.
|
|
func (m *Manager) followDir(ctx context.Context, all []*model.Certificate) {
|
|
st, err := m.store.LoadState(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var reqs []engine.Request
|
|
for _, b := range encdns.TLSBlocks(st) {
|
|
name, ok := strings.CutPrefix(b.Name, encdns.Prefix)
|
|
if !ok || filepath.Dir(b.CertFile) == m.FilesDir(name) {
|
|
continue
|
|
}
|
|
i := slices.IndexFunc(all, func(c *model.Certificate) bool { return c.Name == name })
|
|
if i < 0 || !all[i].HasUse(model.CertUseDNS) {
|
|
continue
|
|
}
|
|
if _, err := os.Stat(filepath.Join(m.FilesDir(name), ChainFile)); err != nil {
|
|
continue // not written there (yet)
|
|
}
|
|
reqs = append(reqs, engine.NewRequest(engine.OpOptionUpdate, b.OptionID, engine.OptionInput{Value: model.Ptr(encdns.TLSValue(name, m.cfg.Dir))}))
|
|
m.log.Info("certificates.dir changed: pointing named at the new files", "tls", b.Name, "was", filepath.Dir(b.CertFile), "now", m.FilesDir(name))
|
|
}
|
|
if len(reqs) == 0 {
|
|
return
|
|
}
|
|
if _, err := m.eng.Submit(ctx, reqs, auth.System, "jarld", "certificates.dir changed: tls statements follow it", engine.ApplyOptions{}); err != nil {
|
|
m.log.Error("pointing named at the moved certificate files failed; run 'jarl encrypted-dns setup' again", "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
|
|
}
|