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>
95 lines
3.1 KiB
Go
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
|
|
}
|