Files
adamandClaude Opus 5.5 fef916b7ab Encrypted DNS: several host names, views per listen address, what named uses a certificate for
The setup takes several host names (one certificate for all; the health check says when the served certificate misses one), stored in the settings. Each listen address gets its own views for the host names' A/AAAA records (--address ADDR=VIEW,..., a view checkbox per address in the form), always local to the view, with a warning when a public view would get a private address; a view no longer given loses the address, other records of the names stay, and the form starts from where the records are now. Certificates (page and jarl cert show) say what named uses each for: DNS over TLS or HTTPS, on which addresses and ports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:49:49 +01:00

260 lines
7.5 KiB
Go

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
}