jarld obtains and renews a certificate for another host; the host asks
for it with one command, waits, downloads it when it is newer than its
copy and runs a hook with the paths in JARL_CERT_* variables.
- Certificates have an owner user; another user can't take the name.
An existing one is obtained again only when a name is added or
removed. A new certificates grant (names=) says which names a user
may have; it is never part of "*". Users may own
certificates.max_per_user certificates (10) or their own cert_limit.
- POST /certificates/{name}/request; owners may list, show, export and
delete theirs; keys only over HTTPS, the socket or loopback; every
download is recorded and shown as "Pulled by".
- CLI: jarl cert get (saved settings, all saved when no name), cert
saved, cert forget, cert schedule (daily systemd timer, Linux, root).
- Migration 14. Tests: server, auth, and e2e against Pebble.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
494 lines
15 KiB
Go
494 lines
15 KiB
Go
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, administrators only).
|
|
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 (for administrators).
|
|
func (s *Server) withOwners(ctx context.Context, views []certView) error {
|
|
pulls, err := s.store.CertificatePulls(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
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 !p.IsAdmin() {
|
|
writeJSON(w, http.StatusOK, map[string]any{"certificates": out})
|
|
return nil
|
|
}
|
|
if err := s.withOwners(r.Context(), out); err != nil {
|
|
return err
|
|
}
|
|
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))
|
|
if p.IsAdmin() {
|
|
vs := []certView{v}
|
|
if err := s.withOwners(r.Context(), 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()
|
|
}
|