Files
jarl/internal/server/h_changes.go
T
adamandClaude Opus 5.5 01ec37b9b2 Explore the statistics: a query log to drill down by client, name, view and more; storage on the dashboard
jarld keeps every answer for a while (stats.retention.queries, default 7 days) in the statistics database: time, client, view, name, type, result and what blocked it. Every client, name, view, record type, result and blocklist on the Statistics page leads to an explore page for it: answers over time, the top values of every other dimension (each a link that narrows further) and the latest answers. GET /stats/explore.

The dashboard shows the size of Jarl's databases (and the query log's rows) and, per disk Jarl uses, how full it is and the databases' share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:15:36 +01:00

422 lines
12 KiB
Go

package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"jarl/internal/api"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/store"
"jarl/internal/version"
)
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")
}
if in.AcceptRisk {
q.Set("accept_risk", "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, AcceptRisk: in.AcceptRisk})
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"`
AcceptRisk bool `json:"accept_risk"`
}
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"),
AcceptRisk: in.AcceptRisk || queryBool(r, "accept_risk")})
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, Build: version.Info(), Engine: raw(st), Time: now, ExpiringTokens: s.expiringTokens(r.Context(), p, now)}
if p.IsAdmin() {
resp.Backup = s.backupStatus(r.Context(), now)
resp.TOTP = s.totpOverview(r.Context())
resp.Certificates = s.certSummary(r.Context())
resp.Storage = s.storage(r.Context())
}
if p.Can(auth.Action{Resource: auth.ResOptions}) {
if h := s.encryptedDNSHealth(); h != nil {
sum := &api.EncDNSSummary{Checked: h.Checked, OK: h.OK(), Problems: []string{}, Warnings: []string{}}
for _, pr := range h.Probes {
where := fmt.Sprintf("%s on %s port %d", map[string]string{"dot": "DNS over TLS", "doh": "DNS over HTTPS"}[pr.Kind], pr.Address, pr.Port)
if pr.Problem != "" {
sum.Problems = append(sum.Problems, where+": "+pr.Problem)
} else if pr.Warning != "" {
sum.Warnings = append(sum.Warnings, where+": "+pr.Warning)
}
}
resp.EncryptedDNS = sum
}
}
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
resp.BlocklistErrors = s.blocklistErrors(r.Context())
if st, err := s.store.LoadState(r.Context()); err == nil {
resp.BlockingPausedUntil = st.Settings.BlockingPausedUntil
resp.BlockingPausedIndefinitely = st.Settings.BlockingPausedIndefinitely
}
}
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
}