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()) } 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 }