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>
410 lines
12 KiB
Go
410 lines
12 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"jarl/internal/api"
|
|
"jarl/internal/auth"
|
|
"jarl/internal/blocking"
|
|
"jarl/internal/engine"
|
|
"jarl/internal/model"
|
|
)
|
|
|
|
// ---- blocking (docs/blocking.md)
|
|
|
|
// bytesPerRecord is what named 9.20 needs per policy zone record and view,
|
|
// measured with lists of a few hundred thousand entries.
|
|
const bytesPerRecord = 630
|
|
|
|
func viewNames(st *model.State, ids []int64) []string {
|
|
out := []string{}
|
|
for _, id := range ids {
|
|
if v := st.Views[id]; v != nil {
|
|
out = append(out, v.Name)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func presentBlocklist(st *model.State, b *model.Blocklist, status *model.BlocklistStatus) api.Blocklist {
|
|
views := st.BlockingViews(b.ViewIDs)
|
|
out := api.Blocklist{Blocklist: *b, Views: viewNames(st, views), AutoViews: len(b.ViewIDs) == 0 && len(st.Views) > 0, Status: status}
|
|
switch {
|
|
case b.Disabled:
|
|
out.State = "disabled"
|
|
case len(views) == 0:
|
|
out.State = "idle"
|
|
case status != nil && status.Error != "":
|
|
out.State = "error"
|
|
case status == nil || status.UpdatedAt == nil:
|
|
out.State = "waiting"
|
|
default:
|
|
out.State = "ok"
|
|
}
|
|
if status != nil && !b.Disabled {
|
|
out.MemoryMB = int((int64(status.Records)*bytesPerRecord*int64(len(views)) + 1<<19) >> 20)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func presentBlockRule(st *model.State, r *model.BlockRule) api.BlockRule {
|
|
return api.BlockRule{BlockRule: *r, Views: viewNames(st, st.BlockingViews(r.ViewIDs)), AutoViews: len(r.ViewIDs) == 0 && len(st.Views) > 0}
|
|
}
|
|
|
|
func requireBlocking(p *auth.Principal) error {
|
|
return require(p, auth.Action{Resource: auth.ResBlocking})
|
|
}
|
|
|
|
func (s *Server) listBlocklists(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireBlocking(p); err != nil {
|
|
return err
|
|
}
|
|
st, base, err := s.states(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
statuses, err := s.store.BlocklistStatuses(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := []api.Blocklist{}
|
|
for _, b := range st.SortedBlocklists() {
|
|
o := presentBlocklist(st, b, statuses[b.ID])
|
|
o.Pending = pending(base, base != nil && base.Blocklists[b.ID] == nil, base != nil && base.Blocklists[b.ID] != nil && base.Blocklists[b.ID].Version != b.Version)
|
|
out = append(out, o)
|
|
}
|
|
if base != nil {
|
|
for _, b := range base.SortedBlocklists() {
|
|
if st.Blocklists[b.ID] == nil {
|
|
o := presentBlocklist(base, b, statuses[b.ID])
|
|
o.Pending = "delete"
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
// pending returns the pending marker of an object listed with a changeset.
|
|
func pending(base *model.State, added, changed bool) string {
|
|
switch {
|
|
case base == nil:
|
|
return ""
|
|
case added:
|
|
return "add"
|
|
case changed:
|
|
return "change"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *Server) blocklistByPath(r *http.Request, st *model.State) (*model.Blocklist, error) {
|
|
ref := r.PathValue("id")
|
|
var b *model.Blocklist
|
|
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
|
b = st.Blocklists[id]
|
|
} else {
|
|
b = st.BlocklistByName(ref)
|
|
}
|
|
if b == nil {
|
|
return nil, notFound("blocklist %s not found", ref)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (s *Server) blocklistObject(ctx context.Context) func(*model.State, int64) any {
|
|
return func(st *model.State, id int64) any {
|
|
b := st.Blocklists[id]
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
statuses, _ := s.store.BlocklistStatuses(ctx)
|
|
return presentBlocklist(st, b, statuses[id])
|
|
}
|
|
}
|
|
|
|
func (s *Server) getBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireBlocking(p); err != nil {
|
|
return err
|
|
}
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b, err := s.blocklistByPath(r, st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, s.blocklistObject(r.Context())(st, b.ID))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) createBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistCreate, 0, body)}, s.blocklistObject(r.Context()))
|
|
}
|
|
|
|
func (s *Server) updateBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b, err := s.blocklistByPath(r, st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := b.ID
|
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistUpdate, id, body)}, func(st *model.State, _ int64) any { return s.blocklistObject(r.Context())(st, id) })
|
|
}
|
|
|
|
func (s *Server) deleteBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b, err := s.blocklistByPath(r, st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlocklistDelete, b.ID, nil)}, nil)
|
|
}
|
|
|
|
// refreshBlocklist: POST /blocking/lists/{id}/refresh[?force=1] downloads
|
|
// the list now and waits for the result.
|
|
func (s *Server) refreshBlocklist(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.Action{Resource: auth.ResBlocking, Write: true}); err != nil {
|
|
return err
|
|
}
|
|
st, err := s.store.LoadState(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b, err := s.blocklistByPath(r, st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
|
defer cancel()
|
|
status, rerr := s.eng.RefreshBlocklist(ctx, b.ID, queryBool(r, "force"))
|
|
if status == nil {
|
|
return rerr
|
|
}
|
|
writeJSON(w, http.StatusOK, presentBlocklist(st, b, status))
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) listBlockRules(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireBlocking(p); err != nil {
|
|
return err
|
|
}
|
|
st, base, err := s.states(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out := []api.BlockRule{}
|
|
for _, rule := range st.SortedBlockRules() {
|
|
o := presentBlockRule(st, rule)
|
|
o.Pending = pending(base, base != nil && base.BlockRules[rule.ID] == nil, base != nil && base.BlockRules[rule.ID] != nil && base.BlockRules[rule.ID].Version != rule.Version)
|
|
out = append(out, o)
|
|
}
|
|
if base != nil {
|
|
for _, rule := range base.SortedBlockRules() {
|
|
if st.BlockRules[rule.ID] == nil {
|
|
o := presentBlockRule(base, rule)
|
|
o.Pending = "delete"
|
|
out = append(out, o)
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
func ruleObject(st *model.State, id int64) any {
|
|
if rule := st.BlockRules[id]; rule != nil {
|
|
return presentBlockRule(st, rule)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) ruleByPath(r *http.Request, st *model.State) (*model.BlockRule, error) {
|
|
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
rule := st.BlockRules[id]
|
|
if rule == nil {
|
|
return nil, notFound("rule %s not found", r.PathValue("id"))
|
|
}
|
|
return rule, nil
|
|
}
|
|
|
|
func (s *Server) createBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleCreate, 0, body)}, ruleObject)
|
|
}
|
|
|
|
func (s *Server) updateBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rule, err := s.ruleByPath(r, st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := rule.ID
|
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleUpdate, id, body)}, func(st *model.State, _ int64) any { return ruleObject(st, id) })
|
|
}
|
|
|
|
func (s *Server) deleteBlockRule(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rule, err := s.ruleByPath(r, st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.mutate(w, r, p, "", []engine.Request{request(engine.OpBlockRuleDelete, rule.ID, nil)}, nil)
|
|
}
|
|
|
|
// deleteBlockRulesByName: DELETE /blocking/rules?name=NAME[&view=VIEW]
|
|
// deletes the rules for a domain (in one view, or all).
|
|
func (s *Server) deleteBlockRulesByName(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
st, err := s.state(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
q := r.URL.Query()
|
|
name := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(q.Get("name"))), ".")
|
|
if name == "" {
|
|
return badRequest("give the domain of the rules to delete (name=)")
|
|
}
|
|
var viewID int64 = -1
|
|
if vn := q.Get("view"); vn != "" {
|
|
v := st.ViewByName(vn)
|
|
if v == nil {
|
|
return notFound("view %s not found", vn)
|
|
}
|
|
viewID = v.ID
|
|
}
|
|
var reqs []engine.Request
|
|
for _, rule := range st.SortedBlockRules() {
|
|
if rule.Name != name {
|
|
continue
|
|
}
|
|
if viewID >= 0 && !slices.Contains(st.BlockingViews(rule.ViewIDs), viewID) {
|
|
continue
|
|
}
|
|
reqs = append(reqs, request(engine.OpBlockRuleDelete, rule.ID, nil))
|
|
}
|
|
if len(reqs) == 0 {
|
|
if queryBool(r, "missing_ok") {
|
|
writeJSON(w, http.StatusOK, api.MutationResponse{})
|
|
return nil
|
|
}
|
|
return notFound("there is no rule for %s", name)
|
|
}
|
|
return s.mutate(w, r, p, "", reqs, nil)
|
|
}
|
|
|
|
// checkBlocking: GET /blocking/check?name=NAME says what blocking does
|
|
// with a name in each view.
|
|
func (s *Server) checkBlocking(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireBlocking(p); err != nil {
|
|
return err
|
|
}
|
|
st, err := s.store.LoadState(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := s.eng.CheckBlocking(st, r.URL.Query().Get("name"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if out == nil {
|
|
out = []blocking.Match{}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|
|
|
|
// blocklistErrors lists the enabled blocklists whose last update failed,
|
|
// for the dashboard.
|
|
func (s *Server) blocklistErrors(ctx context.Context) []api.BlocklistError {
|
|
st, err := s.store.LoadState(ctx)
|
|
if err != nil || len(st.Blocklists) == 0 {
|
|
return nil
|
|
}
|
|
statuses, err := s.store.BlocklistStatuses(ctx)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var out []api.BlocklistError
|
|
for _, b := range st.SortedBlocklists() {
|
|
if s := statuses[b.ID]; s != nil && s.Error != "" && !b.Disabled {
|
|
out = append(out, api.BlocklistError{Name: b.Name, Error: s.Error, UpdatedAt: s.UpdatedAt})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// blockingStatus: GET /blocking/status says whether blocking is paused.
|
|
func (s *Server) blockingStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := requireBlocking(p); err != nil {
|
|
return err
|
|
}
|
|
st, err := s.store.LoadState(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"paused_until": st.Settings.BlockingPausedUntil, "paused_indefinitely": st.Settings.BlockingPausedIndefinitely})
|
|
return nil
|
|
}
|
|
|
|
// pauseBlocking: POST /blocking/pause {"minutes": 10} lets every name
|
|
// through until then, {"indefinitely": true} until resumed; POST
|
|
// /blocking/resume ends it.
|
|
func (s *Server) pauseBlocking(resume bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error {
|
|
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
var in engine.BlockingPauseInput
|
|
if !resume {
|
|
var body struct {
|
|
Minutes int `json:"minutes"`
|
|
Indefinitely bool `json:"indefinitely"`
|
|
}
|
|
if err := decodeJSON(r, &body); err != nil {
|
|
return err
|
|
}
|
|
if body.Indefinitely {
|
|
in.Indefinitely = true
|
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil)
|
|
}
|
|
if body.Minutes < 1 || time.Duration(body.Minutes)*time.Minute > engine.MaxBlockingPause {
|
|
return badRequest("pause for 1 to %d minutes, or indefinitely", int(engine.MaxBlockingPause/time.Minute))
|
|
}
|
|
until := time.Now().Add(time.Duration(body.Minutes) * time.Minute).Truncate(time.Second)
|
|
in.Until = &until
|
|
}
|
|
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil)
|
|
}
|
|
}
|