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 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) } } } } // 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 }