package server import ( "context" "encoding/pem" "errors" "fmt" "net" "net/http" "net/netip" "strings" "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"` // Owner is the user a certificate for another host belongs to; PulledBy // who downloaded it (list and get; an owner who isn't an administrator // sees only their own downloads). Owner string `json:"owner,omitempty"` PulledBy []model.CertificatePull `json:"pulled_by,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 } // certsAccess lets in administrators and users with a certificates grant // (certificates for other hosts: their own only). func (s *Server) certsAccess(p *auth.Principal) error { if s.cfg.Certs == nil { return &httpError{http.StatusNotFound, "disabled", "certificates aren't available in this jarld"} } if p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResCerts}) { return nil } return forbidden("certificates need an administrator, or a certificates grant for their names (certificates:write names=*.example.com)") } // certAllowed reports whether p may see (or, with write, change) c: an // administrator any; anyone else their own, while their grants cover all // its names. func certAllowed(p *auth.Principal, c *model.Certificate, write bool) bool { if p.IsAdmin() { return true } if p.User == nil || c.OwnerID == 0 || c.OwnerID != p.User.ID { return false } for _, d := range c.Domains { if !p.Can(auth.Action{Resource: auth.ResCerts, Write: write, Name: d}) { return false } } return true } // certFor returns the certificate in the path, if p may see it (else not // found, so names of others' certificates don't leak). func (s *Server) certFor(r *http.Request, p *auth.Principal, write bool) (*model.Certificate, error) { c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name")) if err != nil { return nil, certErr(err) } if !certAllowed(p, c, write) { if write && certAllowed(p, c, false) { return nil, forbidden("you may only download certificate %s", c.Name) } return nil, notFound("no such certificate") } return c, nil } // withOwners adds owners and downloads to views: all of them for an // administrator, the principal's own downloads for anyone else (who sees // only their own certificates). func (s *Server) withOwners(ctx context.Context, p *auth.Principal, views []certView) error { pulls, err := s.store.CertificatePulls(ctx) if err != nil { return err } if !p.IsAdmin() { for i := range views { for _, pl := range pulls[views[i].ID] { if p.User != nil && pl.UserID == p.User.ID { views[i].PulledBy = append(views[i].PulledBy, pl) } } if p.User != nil && views[i].OwnerID != 0 { views[i].Owner = p.User.Username } } return nil } users, err := s.store.ListUsers(ctx) if err != nil { return err } names := map[int64]string{} for _, u := range users { names[u.ID] = u.Username } for i := range views { if id := views[i].OwnerID; id != 0 { views[i].Owner = names[id] if views[i].Owner == "" { views[i].Owner = fmt.Sprintf("deleted user %d", id) } } views[i].PulledBy = pulls[views[i].ID] } 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") case errors.Is(err, certs.ErrTaken): return conflict("the certificate name is taken by another user's certificate (or one Jarl uses): choose another name") } return err } // certList: GET /certificates. func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := s.certsAccess(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 { if !certAllowed(p, c, false) { continue } v := s.presentCert(c) v.NamedUse = encdns.CertUses(st, s.cfg.Certs.FilesDir(c.Name)) out = append(out, v) } if err := s.withOwners(r.Context(), p, out); err != nil { return err } if !p.IsAdmin() { writeJSON(w, http.StatusOK, map[string]any{"certificates": out}) return nil } 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.certsAccess(p); err != nil { return err } c, err := s.certFor(r, p, false) if err != nil { return 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)) vs := []certView{v} if err := s.withOwners(r.Context(), p, vs); err != nil { return err } v = vs[0] writeJSON(w, http.StatusOK, v) return nil } // certRequest: POST /certificates/{name}/request {"domains": [...], // "key_type": "ec256"}, for jarl cert get: the certificate for another // host, owned by the caller's user. A new one is created and obtained // (202 while that runs; poll GET /certificates/{name} until // issuing_since is gone). An existing one is the caller's, obtained again // only when its names change; otherwise it is returned as it is (200). func (s *Server) certRequest(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := s.certsAccess(p); err != nil { return err } if p.User == nil || p.User.ID == 0 { return forbidden("certificates for other hosts belong to a user: use an API token, not the local socket as root") } var in struct { Domains []string `json:"domains"` KeyType string `json:"key_type"` } if err := decodeJSON(r, &in); err != nil { return err } for _, d := range in.Domains { d = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(d), ".")) if !p.Can(auth.Action{Resource: auth.ResCerts, Write: true, Name: d}) { return forbidden("you may not get a certificate for %s: that needs certificates:write with a names pattern that matches it", d) } } res, err := s.cfg.Certs.Ensure(r.Context(), r.PathValue("name"), in.Domains, in.KeyType, p) if err != nil { return certErr(err) } v := s.presentCert(res.Certificate) out := map[string]any{"certificate": v, "created": res.Created, "changed": res.Changed} if res.Ignored != "" { out["warnings"] = []string{res.Ignored} } status := http.StatusOK if v.IssuingSince != nil { status = http.StatusAccepted } writeJSON(w, status, out) return nil } // certPEM: GET /certificates/{name}/pem?part=fullchain|cert|key|bundle, // the certificate as PEM for use elsewhere: the chain (default), the // certificate alone, the private key, or the key followed by the chain // (one file, as HAProxy wants). Anything with the key is audited. func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { if err := s.certsAccess(p); err != nil { return err } if _, err := s.certFor(r, p, false); err != nil { return err } part := r.URL.Query().Get("part") if part == "" { part = "fullchain" } if part == "key" || part == "bundle" { if ok, why := s.keyTransport(r); !ok { return &httpError{http.StatusForbidden, "https_required", "a private key is only handed out over HTTPS (or to this server itself), and this request came by " + why} } } c, chain, key, err := s.cfg.Certs.PEM(r.Context(), r.PathValue("name")) if err != nil { return certErr(err) } var out []byte file := c.Name + "-" + part + ".pem" switch part { case "fullchain": out = chain case "cert": if b, _ := pem.Decode(chain); b != nil { out = pem.EncodeToMemory(b) } case "key": out = key case "bundle": out = append(append(append([]byte{}, key...), '\n'), chain...) default: return badRequest("part must be fullchain, cert, key or bundle") } if len(out) == 0 { return badRequest("certificate %s has no %s", c.Name, part) } if part == "key" || part == "bundle" { s.eng.Audit(r.Context(), p, "certificate.export_key", "certificate", c.ID, c.Name, "success", "exported the private key ("+part+")") } pull := &model.CertificatePull{CertID: c.ID, Who: pullWho(p), Serial: c.Serial, IP: s.clientIP(r), At: time.Now().UTC()} if p.User != nil { pull.UserID = p.User.ID } if p.Token != nil { pull.TokenID = p.Token.ID } if err := s.store.RecordCertificatePull(r.Context(), pull); err != nil { s.log.Warn("recording a certificate download failed", "certificate", c.Name, "err", err) } w.Header().Set("Content-Type", "application/x-pem-file") w.Header().Set("Content-Disposition", `attachment; filename="`+file+`"`) w.Header().Set("Cache-Control", "no-store") _, err = w.Write(out) return err } // 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.certsAccess(p); err != nil { return err } if _, err := s.certFor(r, p, true); 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 } // keyTransport reports whether a private key may go back over r: HTTPS // (also through a trusted proxy), the local socket, or loopback. func (s *Server) keyTransport(r *http.Request) (bool, string) { https, why := s.transport(r) if https { return true, why } host, _, _ := net.SplitHostPort(r.RemoteAddr) if a, err := netip.ParseAddr(host); err == nil && a.Unmap().IsLoopback() && !s.trusted(a.Unmap()) { return true, "plain HTTP from this server" } return false, why } // pullWho names a downloader: its token (and user), or the user. func pullWho(p *auth.Principal) string { switch { case p.Token != nil: return "token " + p.Token.Name + " (" + p.Name() + ")" case p.User != nil && p.Method != "": return p.Method + " " + p.Name() } return p.Name() }