Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
400 lines
12 KiB
Go
400 lines
12 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"
|
|
"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")
|
|
}
|
|
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, 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())
|
|
}
|
|
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
|
|
}
|