Files
adamandClaude Opus 5.5 9db76a113c 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>
2026-10-18 16:58:40 +01:00

583 lines
16 KiB
Go

package server
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/model"
"jarl/internal/store"
)
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
se, _ := r.Context().Value(keySession).(*model.Session)
writeJSON(w, http.StatusOK, s.whoamiOf(r.Context(), p, se))
return nil
}
func (s *Server) changeOwnPassword(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if p.Token != nil {
return forbidden("passwords can't be changed with an API token; log in to the web UI")
}
var in api.PasswordChange
if err := decodeJSON(r, &in); err != nil {
return err
}
u, err := s.store.GetUser(r.Context(), p.User.ID)
if err != nil {
return err
}
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Current) {
s.eng.Audit(r.Context(), p, "user.password", "user", u.ID, u.Username, "failure", "wrong current password")
return &httpError{http.StatusForbidden, "wrong_password", "the current password is wrong"}
}
hash, err := auth.HashPassword(in.Password)
if err != nil {
return badRequest("%v", err)
}
u.PasswordHash = hash
if err := s.store.UpdateUser(r.Context(), u); err != nil {
return err
}
// Log out other sessions.
keep := ""
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
keep = se.IDHash
}
s.store.DeleteUserSessions(r.Context(), u.ID, keep)
s.eng.Audit(r.Context(), p, "user.password", "user", u.ID, u.Username, "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
// grantsFrom builds the grant list from structured grants, compact
// strings or a role template.
func grantsFrom(grants *[]model.Grant, strs *[]string, role string) ([]model.Grant, bool, error) {
var out []model.Grant
set := false
if role != "" {
t, ok := auth.RoleTemplates[role]
if !ok {
var names []string
for n := range auth.RoleTemplates {
names = append(names, n)
}
return nil, false, badRequest("unknown role %q (known: %s)", role, strings.Join(names, ", "))
}
out = append(out, t...)
set = true
}
if grants != nil {
for _, g := range *grants {
if err := auth.ValidateGrant(&g); err != nil {
return nil, false, badRequest("%v", err)
}
out = append(out, g)
}
set = true
}
if strs != nil {
for _, gs := range *strs {
g, err := auth.ParseGrant(gs)
if err != nil {
return nil, false, badRequest("grant %q: %v", gs, err)
}
out = append(out, g)
}
set = true
}
if set && out == nil {
out = []model.Grant{}
}
return out, set, nil
}
func parseExpiry(in api.TokenInput) (*time.Time, error) {
if in.ExpiresAt != nil {
if in.ExpiresAt.Before(time.Now()) {
return nil, badRequest("expires_at is in the past")
}
return in.ExpiresAt, nil
}
if in.ExpiresIn == "" {
return nil, nil
}
d, err := parseDays(in.ExpiresIn)
if err != nil || d <= 0 {
return nil, badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
}
t := time.Now().Add(d)
return &t, nil
}
// parseDays parses a duration in Go syntax ("720h", "15m") or in days
// ("90d").
func parseDays(v string) (time.Duration, error) {
v = strings.TrimSpace(v)
if n, ok := strings.CutSuffix(v, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days < 0 {
return 0, fmt.Errorf("invalid duration %q", v)
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(v)
}
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
var in api.TokenInput
if err := decodeJSON(r, &in); err != nil {
return err
}
// A token outlives the session: with two-factor login, making one
// needs a recent code.
if err := s.stepUp(r, p); err != nil {
return err
}
if strings.TrimSpace(in.Name) == "" {
return badRequest("a token needs a name")
}
grants, set, err := grantsFrom(in.Grants, in.GrantStrings, in.Role)
if err != nil {
return err
}
exp, err := parseExpiry(in)
if err != nil {
return err
}
// A restricted token can only create tokens that are no wider than
// itself.
if p.Token != nil && p.Token.Grants != nil {
if !set {
grants = p.Token.Grants
} else if !narrower(grants, p.Token.Grants) {
return forbidden("a restricted token can only create tokens with a subset of its own grants")
}
}
secret, pid, hash := auth.NewToken()
t := &model.Token{UserID: u.ID, Name: strings.TrimSpace(in.Name), PublicID: pid, SecretHash: hash, ExpiresAt: exp}
if set {
t.Grants = grants
}
if err := s.store.CreateToken(r.Context(), t); err != nil {
return err
}
t.Username = u.Username
s.eng.Audit(r.Context(), p, "token.create", "token", t.ID, u.Username+"/"+t.Name, "success", "")
out := presentToken(t)
out.Secret = secret
writeJSON(w, http.StatusCreated, out)
return nil
}
// narrower reports whether every grant in a is covered by a grant in b
// (a conservative check: identical or b is unfiltered for the resource).
func narrower(a, b []model.Grant) bool {
for _, g := range a {
ok := false
for _, h := range b {
if (h.Resource == auth.ResAll || h.Resource == g.Resource) && (h.Access == auth.Write || g.Access == auth.Read) &&
len(h.Zones) == 0 && len(h.Views) == 0 && len(h.Names) == 0 && len(h.Types) == 0 {
ok = true
break
}
if auth.FormatGrant(g) == auth.FormatGrant(h) {
ok = true
break
}
}
if !ok {
return false
}
}
return true
}
func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if p.User.ID == 0 {
return badRequest("%s on the local socket isn't a Jarl user and has no tokens; create the token for a user (jarl token create NAME --user admin)", p.Name())
}
return s.createTokenFor(w, r, p, p.User)
}
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, userID int64) error {
ts, err := s.store.ListTokens(r.Context(), userID)
if err != nil {
return err
}
out := []api.Token{}
for _, t := range ts {
if t.RevokedAt != nil && !queryBool(r, "all") {
continue
}
out = append(out, presentToken(t))
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) listOwnTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
return s.listTokens(w, r, p.User.ID)
}
func (s *Server) listAllTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
return s.listTokens(w, r, 0)
}
func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
t, err := s.store.GetToken(r.Context(), id)
if err != nil {
return err
}
if t.UserID != p.User.ID && !p.IsAdmin() {
return notFound("token %d not found", id)
}
if err := s.store.RevokeToken(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
return conflict("token %d is already revoked", id)
}
return err
}
s.eng.Audit(r.Context(), p, "token.revoke", "token", t.ID, t.Username+"/"+t.Name, "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
// maxRotationGrace is the longest an old secret may keep working.
const maxRotationGrace = 7 * 24 * time.Hour
// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. The
// token keeps its ID, name, user and grants, so nothing needs to be set up
// again; only the clients that use it need the new secret. The old secret
// stops working at once, or after the grace period. A token that expires
// gets the same lifetime again, counted from now, unless expires_in or
// expires_at says otherwise.
func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.TokenRotation
body, err := readBody(r)
if err != nil {
return err
}
if body != nil {
dec := json.NewDecoder(bytes.NewReader(body))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
return badRequest("invalid JSON: %v", err)
}
}
t, err := s.store.GetToken(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return notFound("token %d not found", id)
}
return err
}
if t.UserID != p.User.ID && !p.IsAdmin() {
return notFound("token %d not found", id)
}
// A restricted token may rotate itself, and tokens no wider than
// itself, but not a wider token of the same user: it would learn that
// token's new secret.
if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !narrower(t.Grants, p.Token.Grants)) {
return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants")
}
if t.RevokedAt != nil {
return conflict("token %d is revoked; create a new one", id)
}
if err := s.stepUp(r, p); err != nil {
return err
}
now := time.Now()
var exp *time.Time
switch {
case in.ExpiresIn != "" || in.ExpiresAt != nil:
if in.NoExpiry {
return badRequest("no_expiry can't be combined with expires_in or expires_at")
}
if exp, err = parseExpiry(api.TokenInput{ExpiresIn: in.ExpiresIn, ExpiresAt: in.ExpiresAt}); err != nil {
return err
}
case in.NoExpiry:
case t.ExpiresAt != nil:
life := t.ExpiresAt.Sub(t.IssuedAt())
if life <= 0 {
return badRequest("can't tell the token's lifetime; give expires_in")
}
e := now.Add(life)
exp = &e
}
var prevUntil *time.Time
if in.Grace != "" {
g, err := parseDays(in.Grace)
if err != nil || g < 0 || g > maxRotationGrace {
return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace)
}
until := now.Add(g)
if t.ExpiresAt != nil && t.ExpiresAt.Before(until) {
until = *t.ExpiresAt // the old secret never outlives its token
}
if g > 0 && until.After(now) {
prevUntil = &until
}
}
secret, hash := auth.RotateToken(t.PublicID)
if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil {
if errors.Is(err, store.ErrNotFound) {
return conflict("token %d is revoked; create a new one", id)
}
return err
}
t, err = s.store.GetToken(r.Context(), id)
if err != nil {
return err
}
detail := "old secret stops working now"
if prevUntil != nil {
detail = "old secret works until " + prevUntil.UTC().Format(time.RFC3339)
}
if t.ExpiresAt != nil {
detail += "; expires " + t.ExpiresAt.UTC().Format(time.RFC3339)
}
s.eng.Audit(r.Context(), p, "token.rotate", "token", t.ID, t.Username+"/"+t.Name, "success", detail)
out := presentToken(t)
out.Secret = secret
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
us, err := s.store.ListUsers(r.Context())
if err != nil {
return err
}
out := []api.User{}
for _, u := range us {
out = append(out, s.userOut(u))
}
writeJSON(w, http.StatusOK, out)
return nil
}
func validUsername(n string) error {
if len(n) < 1 || len(n) > 64 {
return badRequest("usernames must be 1 to 64 characters")
}
for _, c := range n {
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._-@", c)) {
return badRequest("usernames may contain letters, digits, '.', '_', '-' and '@'")
}
}
return nil
}
func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
var in api.UserInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if in.Username == nil {
return badRequest("a user needs a username")
}
u := &model.User{Username: strings.TrimSpace(*in.Username)}
if err := validUsername(u.Username); err != nil {
return err
}
if err := applyUserInput(u, &in); err != nil {
return err
}
if err := s.stepUp(r, p); err != nil {
return err
}
if err := s.store.CreateUser(r.Context(), u); err != nil {
if errors.Is(err, store.ErrConflict) {
return conflict("user %s already exists", u.Username)
}
return err
}
s.eng.Audit(r.Context(), p, "user.create", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
writeJSON(w, http.StatusCreated, s.userOut(u))
return nil
}
func applyUserInput(u *model.User, in *api.UserInput) error {
role := ""
if in.Role != nil {
role = *in.Role
}
grants, set, err := grantsFrom(in.Grants, in.GrantStrings, role)
if err != nil {
return err
}
if set {
u.Grants = grants
}
if in.Admin != nil {
u.Admin = *in.Admin
}
if in.Disabled != nil {
u.Disabled = *in.Disabled
}
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
} else {
h, err := auth.HashPassword(*in.Password)
if err != nil {
return badRequest("%v", err)
}
u.PasswordHash = h
}
}
return nil
}
func (s *Server) userByPath(r *http.Request) (*model.User, error) {
id, err := pathID(r, "id")
if err != nil {
return nil, err
}
u, err := s.store.GetUser(r.Context(), id)
if errors.Is(err, store.ErrNotFound) {
return nil, notFound("user %d not found", id)
}
return u, err
}
func (s *Server) getUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
u, err := s.userByPath(r)
if err != nil {
return err
}
if u.ID != p.User.ID && !p.IsAdmin() {
return forbidden("only administrators can see other users")
}
writeJSON(w, http.StatusOK, s.userOut(u))
return nil
}
func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
u, err := s.userByPath(r)
if err != nil {
return err
}
var in api.UserInput
if err := decodeJSON(r, &in); err != nil {
return err
}
wasAdmin := u.Admin && !u.Disabled
if in.Username != nil {
u.Username = strings.TrimSpace(*in.Username)
if err := validUsername(u.Username); err != nil {
return err
}
}
if err := applyUserInput(u, &in); err != nil {
return err
}
if err := s.stepUp(r, p); err != nil {
return err
}
if wasAdmin && (!u.Admin || u.Disabled) {
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
return conflict("%s is the last administrator; make someone else an administrator first", u.Username)
}
}
if err := s.store.UpdateUser(r.Context(), u); err != nil {
if errors.Is(err, store.ErrConflict) {
return conflict("user %s already exists", u.Username)
}
return err
}
if u.Disabled || in.Password != nil {
s.store.DeleteUserSessions(r.Context(), u.ID, "")
}
s.eng.Audit(r.Context(), p, "user.update", "user", u.ID, u.Username, "success", strings.Join(presentUser(u).GrantStrings, "; "))
writeJSON(w, http.StatusOK, s.userOut(u))
return nil
}
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
u, err := s.userByPath(r)
if err != nil {
return err
}
if u.ID == p.User.ID {
return conflict("you can't delete yourself")
}
if err := s.stepUp(r, p); err != nil {
return err
}
if u.Admin && !u.Disabled {
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
return conflict("%s is the last administrator", u.Username)
}
}
if err := s.store.DeleteUser(r.Context(), u.ID); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "user.delete", "user", u.ID, u.Username, "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
func (s *Server) listUserTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
u, err := s.userByPath(r)
if err != nil {
return err
}
if u.ID != p.User.ID && !p.IsAdmin() {
return forbidden("only administrators can see other users' tokens")
}
return s.listTokens(w, r, u.ID)
}
func (s *Server) createUserToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
u, err := s.userByPath(r)
if err != nil {
return err
}
if u.ID != p.User.ID && !p.IsAdmin() {
return forbidden("only administrators can create tokens for other users")
}
return s.createTokenFor(w, r, p, u)
}