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}) return nil } // pauseBlocking: POST /blocking/pause {"minutes": 10} lets every name // through until then; 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"` } if err := decodeJSON(r, &body); err != nil { return err } if body.Minutes < 1 || time.Duration(body.Minutes)*time.Minute > engine.MaxBlockingPause { return badRequest("pause for 1 to %d minutes", 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) } }