package server import ( "context" "errors" "net/http" "time" "jarl/internal/api" "jarl/internal/auth" "jarl/internal/certs" "jarl/internal/encdns" "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) // NamedUse says what of named's configuration uses it: "DNS over TLS // on 10.0.10.5 port 853" (list and get only). NamedUse []string `json:"named_use,omitempty"` // 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 } st, err := s.store.LoadState(r.Context()) if err != nil { return err } out := []certView{} for _, c := range all { v := s.presentCert(c) v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name)) out = append(out, v) } 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) } st, err := s.store.LoadState(r.Context()) if err != nil { return err } v := s.presentCert(c) v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name)) writeJSON(w, http.StatusOK, v) 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 }