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>
244 lines
7.0 KiB
Go
244 lines
7.0 KiB
Go
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
|
|
}
|