jarl block pause|resume (and the Blocking page) lets every name through for a while; jarld resumes on time. The lists stay loaded: the rules zone just passes everything. Statistics gain the clients with the most blocked queries and blocked queries on the queries chart. The view choice in the blocking forms is a dropdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
404 lines
11 KiB
Go
404 lines
11 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})
|
|
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)
|
|
}
|
|
}
|