TLS certificates: Jarl obtains and renews them over ACME
Jarl is now an ACME client (lego's core, no DNS providers). It answers DNS-01 challenges in a zone it serves, or through Porkbun's DNS API while Porkbun serves the domain; "auto" decides from the domain's public name servers at every renewal, and a CNAME on _acme-challenge is followed. Certificates are used for jarld's HTTPS (served without a restart; each port speaks HTTPS and plain HTTP, plain HTTP from loopback and trusted proxies still served) and/or written for named's DNS over TLS/HTTPS, with rndc reconfig on renewal. Renewal follows the CA's ARI window or two thirds of the lifetime, with back-off. Certificates you renew yourself can be used too. Certificates page and dashboard card, jarl cert, /api/v1/certificates, Prometheus gauges, docs/certificates.md, and an e2e section against Pebble. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
88965096bb
commit
88f63ffb0a
43 files changed
+4584
-45
No files matched your search
@@ -0,0 +1,243 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"jarl/internal/api"
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/certs"
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// TLS certificates (docs/certificates.md). Administrators only: a
|
||||
// certificate's key can impersonate the server.
|
||||
|
||||
type certView struct {
|
||||
*model.Certificate
|
||||
Status certs.Status `json:"status"`
|
||||
NextTry *time.Time `json:"next_attempt,omitempty"` // acme: when the loop obtains it next
|
||||
Files *certFiles `json:"files,omitempty"` // dns use: where named reads it
|
||||
Serving bool `json:"serving,omitempty"` // jarld serves it now (web use)
|
||||
// IssuingSince is set while Jarl is obtaining it.
|
||||
IssuingSince *time.Time `json:"issuing_since,omitempty"`
|
||||
}
|
||||
|
||||
type certFiles struct {
|
||||
Chain string `json:"chain"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func (s *Server) presentCert(c *model.Certificate) certView {
|
||||
m := s.cfg.Certs
|
||||
v := certView{Certificate: c, Status: m.StatusOf(c), Serving: m.WebName() == c.Name && c.HasUse(model.CertUseWeb)}
|
||||
if c.Source == model.CertSourceACME {
|
||||
t := m.Due(c)
|
||||
v.NextTry = &t
|
||||
}
|
||||
if t := m.Issuing(c.Name); !t.IsZero() {
|
||||
v.IssuingSince = &t
|
||||
}
|
||||
if c.HasUse(model.CertUseDNS) && c.NotAfter != nil {
|
||||
dir := m.FilesDir(c.Name)
|
||||
v.Files = &certFiles{Chain: dir + "/" + certs.ChainFile, Key: dir + "/" + certs.KeyFile}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Server) certsReady(p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.cfg.Certs == nil {
|
||||
return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func certErr(err error) error {
|
||||
var ie *certs.InputError
|
||||
switch {
|
||||
case errors.As(err, &ie):
|
||||
return badRequest("%s", ie.Msg)
|
||||
case errors.Is(err, certs.ErrNotFound):
|
||||
return notFound("no such certificate")
|
||||
case errors.Is(err, certs.ErrConflict):
|
||||
return conflict("a certificate with that name exists")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// certList: GET /certificates.
|
||||
func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := s.cfg.Certs.List(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []certView{}
|
||||
for _, c := range all {
|
||||
out = append(out, s.presentCert(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificates": out, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certGet: GET /certificates/{name}.
|
||||
func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.presentCert(c))
|
||||
return nil
|
||||
}
|
||||
|
||||
// certCreate: POST /certificates. With "issue": true (the default for
|
||||
// certificates Jarl obtains) it is obtained before the answer.
|
||||
func (s *Server) certCreate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
certs.Input
|
||||
Issue *bool `json:"issue"`
|
||||
}
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Create(r.Context(), in.Input, p)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
if c.Source == model.CertSourceACME && (in.Issue == nil || *in.Issue) {
|
||||
return s.writeIssue(w, r, c.Name, p, http.StatusCreated)
|
||||
}
|
||||
if c.Source == model.CertSourceACME {
|
||||
s.cfg.Certs.Kick()
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"certificate": s.presentCert(c)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certUpdate: PATCH /certificates/{name}.
|
||||
func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in certs.Input
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := s.cfg.Certs.Update(r.Context(), r.PathValue("name"), in, p)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"certificate": s.presentCert(c)})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certDelete: DELETE /certificates/{name}.
|
||||
func (s *Server) certDelete(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.cfg.Certs.Delete(r.Context(), r.PathValue("name"), p); err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certIssue: POST /certificates/{name}/issue starts obtaining it now
|
||||
// (202), or rereads the files of a file-source certificate (200).
|
||||
func (s *Server) certIssue(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.writeIssue(w, r, r.PathValue("name"), p, http.StatusOK)
|
||||
}
|
||||
|
||||
// writeIssue starts obtaining the certificate (it takes a while: poll
|
||||
// GET /certificates/{name} until issuing_since is gone).
|
||||
func (s *Server) writeIssue(w http.ResponseWriter, r *http.Request, name string, p *auth.Principal, status int) error {
|
||||
c, err := s.cfg.Certs.IssueAsync(r.Context(), name, p)
|
||||
if c == nil {
|
||||
return certErr(err)
|
||||
}
|
||||
if c.Source == model.CertSourceACME && status == http.StatusOK {
|
||||
status = http.StatusAccepted
|
||||
}
|
||||
out := map[string]any{"certificate": s.presentCert(c)}
|
||||
if err != nil {
|
||||
out["error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// certCheck: POST /certificates/{name}/check (or POST /certificates/check
|
||||
// with a certificate's fields) says where each challenge would go, without
|
||||
// asking the CA for anything.
|
||||
func (s *Server) certCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := s.certsReady(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var c *model.Certificate
|
||||
if name := r.PathValue("name"); name != "" {
|
||||
var err error
|
||||
if c, err = s.cfg.Certs.Get(r.Context(), name); err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
} else {
|
||||
var in certs.Input
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Name == nil {
|
||||
in.Name = model.Ptr("check")
|
||||
}
|
||||
var err error
|
||||
if c, err = s.cfg.Certs.Preview(in); err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
}
|
||||
routes, err := s.cfg.Certs.Check(r.Context(), c)
|
||||
if err != nil {
|
||||
return certErr(err)
|
||||
}
|
||||
ok := true
|
||||
for _, rt := range routes {
|
||||
ok = ok && rt.Error == ""
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "routes": routes, "account": s.cfg.Certs.AccountInfo(r.Context())})
|
||||
return nil
|
||||
}
|
||||
|
||||
// certSummary sums up the certificates for the dashboard (nil without any).
|
||||
func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
|
||||
if s.cfg.Certs == nil {
|
||||
return nil
|
||||
}
|
||||
all, err := s.cfg.Certs.List(ctx)
|
||||
if err != nil || len(all) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := &api.CertSummary{Total: len(all), Problems: []api.CertProblem{}, Serving: s.cfg.Certs.WebName()}
|
||||
for _, c := range all {
|
||||
if st := s.cfg.Certs.StatusOf(c); st.State != "ok" && st.State != "pending" {
|
||||
out.Problems = append(out.Problems, api.CertProblem{Name: c.Name, State: st.State, Message: st.Message})
|
||||
}
|
||||
if c.NotAfter != nil && (out.NextAfter == nil || c.NotAfter.Before(*out.NextAfter)) {
|
||||
out.Next, out.NextAfter = c.Name, c.NotAfter
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user