Files
abrown38andClaude Opus 5.5 7b3f0f764c The web UI's certificate from Let's Encrypt, through Jarl
tls.acme names the certificate and jarl: the Jarl server and a token that
may only write the challenge record. Steward gets the certificate with
DNS-01 (lego), writing the record through Jarl's API into every view that
has the zone, renews it with a third of its lifetime left and swaps it
in without a restart. A check runs before every order: Jarl, the zone,
the token's permission, a test record, the zone's name servers, public
resolvers, clean-up; each step says what to fix. Staging by default.

With a certificate, each listener serves HTTPS and plain HTTP on one port
(redirecting plain HTTP, except from the server itself).

steward cert / cert check / cert renew, Server -> Certificate in the web
UI, GET /certificate, POST /certificate/check|renew. docs/certificates.md
and docs/jarl.md (the Jarl user and the token's grant).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:46:36 +01:00

95 lines
3.1 KiB
Go

package server
import (
"context"
"fmt"
"net/http"
"time"
"steward/internal/api"
"steward/internal/auth"
"steward/internal/certs"
"steward/internal/validate"
)
func (s *Server) certificate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
out := api.CertificateResponse{Source: "none"}
switch {
case s.cfg.ACME != nil:
st := s.cfg.ACME.Status()
out.Configured, out.Source, out.Status = true, "acme", &st
case s.cfg.TLSCertFile != "":
out.Source = "file"
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) acmeManager() (*certs.Manager, error) {
if s.cfg.ACME == nil {
return nil, conflict("stewardd doesn't obtain its certificate: set tls.acme and jarl in steward.yaml (docs/certificates.md)")
}
return s.cfg.ACME, nil
}
// certificateCheck: POST /certificate/check runs the preflight check now
// (it takes up to a few minutes when the record is slow to appear).
func (s *Server) certificateCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
m, err := s.acmeManager()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
defer cancel()
c := m.Preflight(ctx)
s.eng.Audit(r.Context(), p, "certificate.check", m.Status().Domain, map[bool]string{true: "success", false: "failure"}[c.OK], "")
writeJSON(w, http.StatusOK, c)
return nil
}
// certificateRenew: POST /certificate/renew gets a new certificate now, in
// the background (GET /certificate follows it).
func (s *Server) certificateRenew(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
m, err := s.acmeManager()
if err != nil {
return err
}
m.Kick()
s.eng.Audit(r.Context(), p, "certificate.renew", m.Status().Domain, "success", "")
st := m.Status()
writeJSON(w, http.StatusAccepted, api.CertificateResponse{Configured: true, Source: "acme", Status: &st})
return nil
}
// certificateProblems are the certificate's problems for /status.
func (s *Server) certificateProblems() []validate.Problem {
if s.cfg.ACME == nil {
return nil
}
st := s.cfg.ACME.Status()
obj := "certificate " + st.Domain
var out []validate.Problem
if st.LastError != "" {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_failed", Object: obj,
Message: "getting the web UI's certificate failed: " + st.LastError})
}
if st.NotAfter != nil && time.Until(*st.NotAfter) < 14*24*time.Hour {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_expiring", Object: obj,
Message: fmt.Sprintf("the certificate ends %s and hasn't been renewed", st.NotAfter.Local().Format("2006-01-02 15:04"))})
}
if st.State == "ok" && st.Staging {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_staging", Object: obj,
Message: "the certificate is from Let's Encrypt's staging service, which browsers don't trust: once it works, set tls.acme.directory: letsencrypt and restart stewardd"})
}
return out
}