- Daily database backups: jarl-backup.timer runs 'jarld backup', which writes jarl-<UTC time>.db into backup.dir, checks it before naming it, and prunes by the time in the name while always keeping the newest keep_at_least. New backup: section in jarl.yaml; the dashboard and 'jarl status' show the newest backup and warn when it is stale or failed. The Debian package enables the timer. - 'jarld restore [FILE | latest]': checks the backup, shows it next to the current database, keeps the current one aside and records the restore. 'jarld serve' holds a database lock, so a restore can't run under a live jarld and two jarlds can't share a database. - Token expiry warnings from 14 days ahead: dashboard, token lists, a daily log line, and a CLI warning via the Jarl-Token-Expires header; 'jarl token list --expiring'. - 'jarl zone serials' and the zone page's Name servers tab: compare the serial on a primary zone's secondaries (NS records, also-notify) or a secondary zone's primaries with this server's. - 'jarl zone delegation': for zones on the internet, check the parent's delegation and glue against the zone; an undelegated zone gets a note. - Server: Jarl/<version> header; CLI exit code 2 for failed checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
391 lines
11 KiB
Go
391 lines
11 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/auth"
|
|
"jarl/internal/engine"
|
|
"jarl/internal/model"
|
|
"jarl/internal/store"
|
|
)
|
|
|
|
func toRequests(in []api.ChangeRequest) []engine.Request {
|
|
out := make([]engine.Request, len(in))
|
|
for i, c := range in {
|
|
out[i] = engine.Request{Op: c.Op, ID: c.ID, BaseVersion: c.BaseVersion, Data: c.Data}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func presentChangeset(cs *model.Changeset) api.Changeset {
|
|
c := *cs
|
|
c.Ops = engine.RedactOps(cs.Ops)
|
|
undoable := false
|
|
if cs.Status == model.ChangesetApplied && len(cs.Result) > 0 {
|
|
var r struct {
|
|
Undo []json.RawMessage `json:"undo"`
|
|
}
|
|
if json.Unmarshal(cs.Result, &r) == nil {
|
|
undoable = len(r.Undo) > 0
|
|
}
|
|
// The undo operations hold old TSIG secrets; don't send them.
|
|
var m map[string]json.RawMessage
|
|
if json.Unmarshal(cs.Result, &m) == nil {
|
|
delete(m, "undo")
|
|
c.Result, _ = json.Marshal(m)
|
|
}
|
|
}
|
|
return api.Changeset{Changeset: c, Undoable: undoable}
|
|
}
|
|
|
|
// applyBatch: POST /apply applies several requests atomically.
|
|
func (s *Server) applyBatch(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
var in api.BatchRequest
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
if len(in.Ops) == 0 {
|
|
return badRequest("no operations given")
|
|
}
|
|
q := r.URL.Query()
|
|
if in.DryRun {
|
|
q.Set("dry_run", "1")
|
|
}
|
|
if in.Force {
|
|
q.Set("force", "1")
|
|
}
|
|
r.URL.RawQuery = q.Encode()
|
|
return s.mutate(w, r, p, in.Description, toRequests(in.Ops), nil)
|
|
}
|
|
|
|
func (s *Server) listChangesets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
f := store.ChangesetFilter{Status: r.URL.Query().Get("status"), Limit: queryInt(r, "limit", 100), Before: int64(queryInt(r, "before", 0))}
|
|
all := p.IsAdmin() || p.Can(auth.Action{Resource: auth.ResAudit})
|
|
if !all || queryBool(r, "mine") {
|
|
f.UserID = p.User.ID
|
|
}
|
|
list, err := s.store.ListChangesets(r.Context(), f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := []api.Changeset{}
|
|
for _, cs := range list {
|
|
out = append(out, presentChangeset(cs))
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
var in api.BatchRequest
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(body) > 0 {
|
|
if err := json.Unmarshal(body, &in); err != nil {
|
|
return badRequest("invalid JSON: %v", err)
|
|
}
|
|
}
|
|
cs, err := s.eng.CreateChangeset(r.Context(), p, via(r, p), in.Description)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(in.Ops) > 0 {
|
|
res, err := s.eng.Stage(r.Context(), cs.ID, toRequests(in.Ops), p)
|
|
if err != nil {
|
|
s.eng.Discard(r.Context(), cs.ID, p)
|
|
return err
|
|
}
|
|
cs = res.Changeset
|
|
if in.Apply {
|
|
out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force})
|
|
if err != nil {
|
|
s.writeErr(w, r, err, out)
|
|
return nil
|
|
}
|
|
writeJSON(w, http.StatusCreated, api.MutationResponse{Applied: out.Applied, DryRun: out.DryRun, ChangesetID: cs.ID, Result: raw(out), Warnings: out.Warnings})
|
|
return nil
|
|
}
|
|
writeJSON(w, http.StatusCreated, api.MutationResponse{Staged: true, ChangesetID: cs.ID, Changeset: raw(presentChangeset(cs)), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
|
|
return nil
|
|
}
|
|
writeJSON(w, http.StatusCreated, presentChangeset(cs))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) changesetByPath(r *http.Request, p *auth.Principal) (*model.Changeset, error) {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cs, err := s.store.GetChangeset(r.Context(), id)
|
|
if errors.Is(err, store.ErrNotFound) || err == nil && !engine.CanAccessChangeset(p, cs, false) {
|
|
return nil, notFound("changeset %d not found", id)
|
|
}
|
|
return cs, err
|
|
}
|
|
|
|
func (s *Server) getChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
cs, err := s.changesetByPath(r, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, presentChangeset(cs))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) updateChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var in struct {
|
|
Description string `json:"description"`
|
|
}
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
cs, err := s.eng.UpdateDescription(r.Context(), id, in.Description, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, presentChangeset(cs))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) stageOps(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var in api.BatchRequest
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
res, err := s.eng.Stage(r.Context(), id, toRequests(in.Ops), p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: id, Changeset: raw(presentChangeset(res.Changeset)), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) unstageOp(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
seq, err := strconv.Atoi(r.PathValue("seq"))
|
|
if err != nil {
|
|
return badRequest("invalid operation number")
|
|
}
|
|
cs, err := s.eng.RemoveOps(r.Context(), id, []int{seq}, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, presentChangeset(cs))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) previewChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := s.eng.Preview(r.Context(), id, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var in struct {
|
|
DryRun bool `json:"dry_run"`
|
|
Force bool `json:"force"`
|
|
}
|
|
if body, err := readBody(r); err != nil {
|
|
return err
|
|
} else if len(body) > 0 {
|
|
if err := json.Unmarshal(body, &in); err != nil {
|
|
return badRequest("invalid JSON: %v", err)
|
|
}
|
|
}
|
|
res, err := s.eng.Apply(r.Context(), id, p, engine.ApplyOptions{DryRun: in.DryRun || queryBool(r, "dry_run"), Force: in.Force || queryBool(r, "force")})
|
|
if err != nil {
|
|
s.writeErr(w, r, err, res)
|
|
return nil
|
|
}
|
|
writeJSON(w, http.StatusOK, api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, ChangesetID: res.ChangesetID, Result: raw(res), Warnings: res.Warnings})
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) discardChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.eng.Discard(r.Context(), id, p); err != nil {
|
|
return err
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) revertChangeset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
id, err := pathID(r, "id")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cs, err := s.eng.Revert(r.Context(), id, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusCreated, presentChangeset(cs))
|
|
return nil
|
|
}
|
|
|
|
// ---- status, system, audit
|
|
|
|
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.eng.Status(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !p.IsAdmin() && !p.Can(auth.Action{Resource: auth.ResOptions}) {
|
|
// Keep it to the essentials for restricted principals.
|
|
st.Problems = nil
|
|
st.Drift = nil
|
|
if st.Named != nil {
|
|
st.Named.Raw = ""
|
|
}
|
|
}
|
|
now := time.Now()
|
|
resp := api.StatusResponse{Version: s.cfg.Version, Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
|
|
if p.IsAdmin() {
|
|
resp.Backup = s.backupStatus(r.Context(), now)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
return nil
|
|
}
|
|
|
|
// backupStatus reports the newest backup, as 'jarld backup' recorded it,
|
|
// and whether it is older than backup.warn_after. No backup at all is
|
|
// reported but not flagged: backups may be made some other way.
|
|
func (s *Server) backupStatus(ctx context.Context, now time.Time) *api.BackupStatus {
|
|
ok, failed := s.store.LastBackups(ctx)
|
|
bs := &api.BackupStatus{Dir: s.cfg.BackupDir, WarnAfter: int64(s.cfg.BackupWarnAfter / time.Second)}
|
|
if ok != nil {
|
|
bs.LastAt, bs.LastFile, bs.LastSize = &ok.At, ok.File, ok.Size
|
|
bs.Stale = s.cfg.BackupWarnAfter > 0 && now.Sub(ok.At) > s.cfg.BackupWarnAfter
|
|
}
|
|
if failed != nil && (ok == nil || failed.At.After(ok.At)) {
|
|
bs.LastError, bs.LastErrorAt = failed.Error, &failed.At
|
|
}
|
|
return bs
|
|
}
|
|
|
|
// expiringTokens lists the tokens the dashboard warns about: those that
|
|
// expire within model.TokenExpiryWarning or expired less than that long
|
|
// ago. Administrators see everyone's, others their own.
|
|
func (s *Server) expiringTokens(ctx context.Context, p *auth.Principal, now time.Time) []api.Token {
|
|
out := []api.Token{}
|
|
uid := p.User.ID
|
|
if p.IsAdmin() {
|
|
uid = 0
|
|
} else if uid == 0 {
|
|
return out
|
|
}
|
|
ts, err := s.store.ListTokens(ctx, uid)
|
|
if err != nil {
|
|
s.log.Warn("listing tokens failed", "err", err)
|
|
return out
|
|
}
|
|
for _, t := range ts {
|
|
if t.ExpiresWithin(now, model.TokenExpiryWarning) && now.Sub(*t.ExpiresAt) < model.TokenExpiryWarning {
|
|
out = append(out, presentToken(t))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) systemCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
|
|
return err
|
|
}
|
|
res, err := s.eng.CheckCurrent(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) systemResync(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireAdmin(p); err != nil {
|
|
return err
|
|
}
|
|
var in struct {
|
|
DryRun bool `json:"dry_run"`
|
|
Force bool `json:"force"`
|
|
}
|
|
if body, err := readBody(r); err != nil {
|
|
return err
|
|
} else if len(body) > 0 {
|
|
if err := json.Unmarshal(body, &in); err != nil {
|
|
return badRequest("invalid JSON: %v", err)
|
|
}
|
|
}
|
|
res, err := s.eng.Resync(r.Context(), p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force})
|
|
if err != nil {
|
|
s.writeErr(w, r, err, res)
|
|
return nil
|
|
}
|
|
writeJSON(w, http.StatusOK, res)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
q := r.URL.Query()
|
|
f := store.AuditFilter{Username: q.Get("user"), Action: q.Get("action"), Outcome: q.Get("outcome"), ObjectType: q.Get("object_type"),
|
|
ChangesetID: int64(queryInt(r, "changeset", 0)), Search: q.Get("q"), Before: int64(queryInt(r, "before", 0)), Limit: queryInt(r, "limit", 200)}
|
|
for name, dst := range map[string]*time.Time{"since": &f.Since, "until": &f.Until} {
|
|
if v := q.Get(name); v != "" {
|
|
t, err := time.Parse(time.RFC3339, v)
|
|
if err != nil {
|
|
if d, derr := time.Parse("2006-01-02", v); derr == nil {
|
|
t = d
|
|
} else {
|
|
return badRequest("invalid %s %q (use RFC 3339 or YYYY-MM-DD)", name, v)
|
|
}
|
|
}
|
|
*dst = t
|
|
}
|
|
}
|
|
if !p.IsAdmin() && !p.Can(auth.Action{Resource: auth.ResAudit}) {
|
|
// Everyone may see their own activity.
|
|
f.Username = p.User.Username
|
|
}
|
|
evs, err := s.store.ListAudit(r.Context(), f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if evs == nil {
|
|
evs = []*model.AuditEvent{}
|
|
}
|
|
writeJSON(w, http.StatusOK, evs)
|
|
return nil
|
|
}
|