Certificates for other hosts: jarl cert get

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>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-18 16:58:40 +01:00
1 parent b4bb99b482
commit 9db76a113c
29 files changed
+1568 -40

No files matched your search

+173
View File
@@ -0,0 +1,173 @@
package server
import (
"context"
"crypto/tls"
"encoding/json"
"net/http"
"os"
"strings"
"testing"
"time"
"jarl/internal/auth"
"jarl/internal/model"
)
// certUser creates a service user with grants and returns a token.
func certUser(t *testing.T, e *apiEnv, name string, grants ...string) (string, *model.User) {
t.Helper()
gs := []model.Grant{}
for _, s := range grants {
g, err := auth.ParseGrant(s)
if err != nil {
t.Fatal(err)
}
gs = append(gs, g)
}
u := &model.User{Username: name, Grants: gs}
if err := e.st.CreateUser(context.Background(), u); err != nil {
t.Fatal(err)
}
tok, pid, sh := auth.NewToken()
if err := e.st.CreateToken(context.Background(), &model.Token{UserID: u.ID, Name: name + "-host", PublicID: pid, SecretHash: sh}); err != nil {
t.Fatal(err)
}
return tok, u
}
// obtained gives a certificate a current certificate, as if the CA had
// signed it.
func obtained(t *testing.T, e *apiEnv, name string, domains ...string) {
t.Helper()
certFile, keyFile := selfSigned(t, t.TempDir(), domains...)
cb, _ := os.ReadFile(certFile)
kb, _ := os.ReadFile(keyFile)
ctx := context.Background()
for i := 0; i < 50 && e.srv.cfg.Certs.Issuing(name) != (time.Time{}); i++ {
time.Sleep(20 * time.Millisecond) // the failing attempt (no CA) ends
}
c, err := e.st.CertificateByName(ctx, name)
if err != nil {
t.Fatal(err)
}
na, nb := time.Now().Add(30*24*time.Hour), time.Now()
c.CertPEM, c.KeyPEM, c.NotAfter, c.NotBefore, c.Serial, c.Failures, c.LastError = string(cb), string(kb), &na, &nb, "s-"+name, 0, ""
if err := e.st.UpdateCertificate(ctx, c); err != nil {
t.Fatal(err)
}
}
func TestCertGet(t *testing.T) {
e := newAPIEnv(t)
withCerts(e)
mx, mxUser := certUser(t, e, "mx", "certificates:write names=*.adbraun.lol")
other, _ := certUser(t, e, "other", "certificates:write names=*.adbraun.lol")
wide, _ := certUser(t, e, "wide", "*:write")
req := func(tok, name string, domains ...string) (map[string]any, int) {
b, status := e.req("POST", "/certificates/"+name+"/request", tok, map[string]any{"domains": domains})
var out map[string]any
_ = jsonUnmarshal(b, &out)
return out, status
}
// "*" doesn't cover certificates.
if _, st := req(wide, "mail", "mail.adbraun.lol"); st != 403 {
t.Errorf("*:write: %d", st)
}
// Names outside the grant.
if out, st := req(mx, "mail", "mail.example.org"); st != 403 || !strings.Contains(errMsg(out), "mail.example.org") {
t.Errorf("other names: %d %v", st, out)
}
// A new certificate, owned by mx.
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 && st != 202 || out["created"] != true {
t.Fatalf("create: %d %v", st, out)
}
c, _ := e.st.CertificateByName(context.Background(), "mail")
if c.OwnerID != mxUser.ID || len(c.Uses) != 0 {
t.Errorf("owner %d uses %v", c.OwnerID, c.Uses)
}
// Another user can't have the name, or see it.
if out, st := req(other, "mail", "mail.adbraun.lol"); st != 409 || !strings.Contains(errMsg(out), "taken") {
t.Errorf("taken: %d %v", st, out)
}
e.do("GET", "/certificates/mail", other, nil, 404)
if l := e.do("GET", "/certificates", other, nil, 200)["certificates"].([]any); len(l) != 0 {
t.Errorf("other's list: %v", l)
}
// Nothing to download until it is obtained.
e.do("GET", "/certificates/mail/pem", mx, nil, 400)
obtained(t, e, "mail", "mail.adbraun.lol")
// The same names: the same certificate.
if out, st := req(mx, "mail", "mail.adbraun.lol"); st != 200 || out["created"] != false || out["changed"] != false {
t.Errorf("again: %d %v", st, out)
}
if c2, _ := e.st.CertificateByName(context.Background(), "mail"); c2.Serial != "s-mail" {
t.Errorf("replaced: %v", c2.Serial)
}
// Download: recorded, with the token.
if b, st := e.req("GET", "/certificates/mail/pem?part=key", mx, nil); st != 200 || !strings.Contains(string(b), "PRIVATE KEY") {
t.Errorf("key: %d", st)
}
got := e.do("GET", "/certificates/mail", e.token, nil, 200)
pulls, _ := got["pulled_by"].([]any)
if got["owner"] != "mx" || len(pulls) != 1 || !strings.Contains(pulls[0].(map[string]any)["who"].(string), "mx-host") || pulls[0].(map[string]any)["serial"] != "s-mail" {
t.Errorf("owner %v pulled_by %v", got["owner"], got["pulled_by"])
}
// A name added: obtained again.
if out, st := req(mx, "mail", "mail.adbraun.lol", "smtp.adbraun.lol"); out["changed"] != true || st != 200 && st != 202 {
t.Errorf("SAN added: %d %v", st, out)
}
// The owner's own list.
if l := e.do("GET", "/certificates", mx, nil, 200)["certificates"].([]any); len(l) != 1 {
t.Errorf("mx's list: %v", l)
}
// Jarl's own certificates are the administrators'.
certFile, keyFile := selfSigned(t, t.TempDir(), "jarl.adbraun.lol")
e.do("POST", "/certificates", e.token, map[string]any{"name": "web", "source": "file", "cert_file": certFile, "key_file": keyFile}, 201)
if _, st := req(mx, "web", "jarl.adbraun.lol"); st != 409 {
t.Errorf("Jarl's own: %d", st)
}
e.do("GET", "/certificates/web/pem", mx, nil, 404)
// The limit: one more for mx, then no more.
limit := 2
mxUser.CertLimit = &limit
if err := e.st.UpdateUser(context.Background(), mxUser); err != nil {
t.Fatal(err)
}
if out, st := req(mx, "www", "www.adbraun.lol"); st != 200 && st != 202 {
t.Errorf("second: %d %v", st, out)
}
if out, st := req(mx, "third", "x.adbraun.lol"); st != 400 || !strings.Contains(errMsg(out), "limit") {
t.Errorf("over the limit: %d %v", st, out)
}
// The owner may delete theirs.
e.do("DELETE", "/certificates/www", mx, nil, 200)
}
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// errMsg is an error answer's message.
func errMsg(out map[string]any) string {
e, _ := out["error"].(map[string]any)
m, _ := e["message"].(string)
return m
}
// A private key goes back only over HTTPS or to this server itself.
func TestKeyTransport(t *testing.T) {
e := newAPIEnv(t)
for addr, want := range map[string]bool{"127.0.0.1:5000": true, "[::1]:5000": true, "192.0.2.7:5000": false} {
r, _ := http.NewRequest("GET", "/x", nil)
r.RemoteAddr = addr
if ok, why := e.srv.keyTransport(r); ok != want {
t.Errorf("%s: %v (%s)", addr, ok, why)
}
}
r, _ := http.NewRequest("GET", "/x", nil)
r.RemoteAddr, r.TLS = "192.0.2.7:5000", &tls.ConnectionState{}
if ok, _ := e.srv.keyTransport(r); !ok {
t.Error("HTTPS refused")
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func selfSigned(t *testing.T, dir string, names ...string) (certFile, keyFile st
func withCerts(env *apiEnv) *certs.Manager {
quiet := slog.New(slog.NewTextHandler(io.Discard, nil))
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir"}, env.st, env.srv.eng, quiet)
m := certs.New(certs.Config{Dir: filepath.Join(env.t.TempDir(), "tls"), Directory: "https://acme.invalid/dir", MaxPerUser: 10}, env.st, env.srv.eng, quiet)
env.srv.cfg.Certs = m
return m
}
+6
View File
@@ -444,6 +444,12 @@ func applyUserInput(u *model.User, in *api.UserInput) error {
if in.Comment != nil {
u.Comment = *in.Comment
}
if in.CertLimit != nil {
u.CertLimit = in.CertLimit
if *in.CertLimit < 0 {
u.CertLimit = nil
}
}
if in.Password != nil {
if *in.Password == "" {
u.PasswordHash = "" // service account: tokens only
+194 -6
View File
@@ -4,7 +4,11 @@ import (
"context"
"encoding/pem"
"errors"
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"time"
"jarl/internal/api"
@@ -28,6 +32,10 @@ type certView struct {
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 {
@@ -62,6 +70,78 @@ func (s *Server) certsReady(p *auth.Principal) error {
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 {
@@ -71,13 +151,15 @@ func certErr(err error) error {
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.certsReady(p); err != nil {
if err := s.certsAccess(p); err != nil {
return err
}
all, err := s.cfg.Certs.List(r.Context())
@@ -90,22 +172,32 @@ func (s *Server) certList(w http.ResponseWriter, r *http.Request, p *auth.Princi
}
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.certsReady(p); err != nil {
if err := s.certsAccess(p); err != nil {
return err
}
c, err := s.cfg.Certs.Get(r.Context(), r.PathValue("name"))
c, err := s.certFor(r, p, false)
if err != nil {
return certErr(err)
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
@@ -113,22 +205,80 @@ func (s *Server) certGet(w http.ResponseWriter, r *http.Request, p *auth.Princip
}
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.certsReady(p); err != nil {
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)
@@ -155,6 +305,16 @@ func (s *Server) certPEM(w http.ResponseWriter, r *http.Request, p *auth.Princip
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")
@@ -208,7 +368,10 @@ func (s *Server) certUpdate(w http.ResponseWriter, r *http.Request, p *auth.Prin
// 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 {
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 {
@@ -303,3 +466,28 @@ func (s *Server) certSummary(ctx context.Context) *api.CertSummary {
}
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()
}
+1
View File
@@ -175,6 +175,7 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/certificates/check", s.authed(s.certCheck))
m.HandleFunc("GET "+p+"/certificates/{name}", s.authed(s.certGet))
m.HandleFunc("GET "+p+"/certificates/{name}/pem", s.authed(s.certPEM))
m.HandleFunc("POST "+p+"/certificates/{name}/request", s.authed(s.certRequest))
m.HandleFunc("PATCH "+p+"/certificates/{name}", s.authed(s.certUpdate))
m.HandleFunc("DELETE "+p+"/certificates/{name}", s.authed(s.certDelete))
m.HandleFunc("POST "+p+"/certificates/{name}/issue", s.authed(s.certIssue))