Internet exposure checks and response rate limiting

Jarl works out which views are reachable from the internet (match-clients
ending in a catch-all, not private-looking addresses: an internal view of a
global IPv6 prefix is still yours; a server without views: open allow-query
and a non-private listen address), with a per-view override (schema 9), and
checks public ones: rate limiting, open resolver, minimal-any, cache
snooping, open NOTIFY and transfers, version and host name, management
interfaces. Fixes are staged like any change, including rate limiting from
ISC's example in log-only mode with the secondaries exempt, then enforcing.
named logs what rate limiting limits (or would, in log-only mode, where its
counters don't move) as query-errors; Jarl routes those lines to its log
and counts them (rrl breakdown, ratelimited top lists, Prometheus
jarl_ratelimit_logged_total), shown on the new Exposure page.

A change that newly opens a public view up must be confirmed (accept_risk,
--accept-risk, a checkbox on the Changes page; 409 confirm_risk
otherwise). Findings can be acknowledged with a reason and stay visible as
info. GET /exposure, POST /exposure/fix|ack|unack, jarl exposure,
docs/exposure.md.

Also fixes query statistics skipping TSIG-signed queries ("IP#port/key
NAME" in named's log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-12 10:38:56 +01:00
1 parent cf550bd512
commit bdd7acfd3b
50 files changed
+2019 -52

No files matched your search

+68
View File
@@ -0,0 +1,68 @@
package server
import (
"encoding/json"
"strings"
"testing"
)
func TestExposureAPI(t *testing.T) {
e := newAPIEnv(t)
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}}}, 201)
e.do("POST", "/views", e.token, map[string]any{"name": "internal", "match_clients": []string{"192.168.1.0/24", "localhost"}, "adopt_zones": true}, 201)
ext := e.do("POST", "/views", e.token, map[string]any{"name": "external", "match_clients": []string{"any"}}, 201)["object"].(map[string]any)["id"]
type report struct {
Scopes []struct {
View string `json:"view"`
Reason string `json:"reason"`
RateLimit string `json:"rate_limit"`
Public bool `json:"public"`
Findings []struct {
Code, Severity, Fix string
}
}
Server []struct{ Code, Fix string }
}
get := func() report {
b, status := e.req("GET", "/exposure", e.token, nil)
if status != 200 {
t.Fatalf("exposure = %d %s", status, b)
}
var r report
json.Unmarshal(b, &r)
return r
}
r := get()
if len(r.Scopes) != 2 || r.Scopes[0].View != "internal" || r.Scopes[0].Public || !r.Scopes[1].Public {
t.Fatalf("scopes = %+v", r.Scopes)
}
var codes []string
for _, f := range r.Scopes[1].Findings {
codes = append(codes, f.Code)
if f.Code == "exposure_rate_limit" && !strings.Contains(f.Fix, "log-only yes") {
t.Errorf("rate limit fix = %q", f.Fix)
}
}
if !strings.Contains(strings.Join(codes, " "), "exposure_rate_limit") || len(r.Server) < 2 {
t.Errorf("findings = %v, server = %+v", codes, r.Server)
}
e.do("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_rate_limit"}, 201)
if r := get(); r.Scopes[1].RateLimit != "log-only" {
t.Errorf("rate limit = %q", r.Scopes[1].RateLimit)
}
if _, status := e.req("POST", "/exposure/fix", e.token, map[string]any{"view": "external", "code": "exposure_open_notify"}); status != 400 {
t.Errorf("fix without an automatic fix = %d", status)
}
// Opening a public view up needs confirming.
b, status := e.req("POST", "/options", e.token, map[string]any{"scope": "view", "scope_id": ext, "name": "allow-recursion", "value": "any"})
if status != 409 || !strings.Contains(string(b), "confirm_risk") {
t.Errorf("open resolver = %d %s", status, b)
}
e.do("POST", "/exposure/ack", e.token, map[string]any{"view": "external", "code": "exposure_minimal_any", "reason": "testing"}, 201)
for _, f := range get().Scopes[1].Findings {
if f.Code == "exposure_minimal_any" && f.Severity != "info" {
t.Errorf("acknowledged finding = %+v", f)
}
}
}
+9 -4
View File
@@ -61,6 +61,9 @@ func (s *Server) applyBatch(w http.ResponseWriter, r *http.Request, p *auth.Prin
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)
}
@@ -106,7 +109,7 @@ func (s *Server) createChangeset(w http.ResponseWriter, r *http.Request, p *auth
}
cs = res.Changeset
if in.Apply {
out, err := s.eng.Apply(r.Context(), cs.ID, p, engine.ApplyOptions{DryRun: in.DryRun, Force: in.Force})
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
@@ -214,8 +217,9 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
return err
}
var in struct {
DryRun bool `json:"dry_run"`
Force bool `json:"force"`
DryRun bool `json:"dry_run"`
Force bool `json:"force"`
AcceptRisk bool `json:"accept_risk"`
}
if body, err := readBody(r); err != nil {
return err
@@ -224,7 +228,8 @@ func (s *Server) applyChangeset(w http.ResponseWriter, r *http.Request, p *auth.
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")})
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
+146
View File
@@ -0,0 +1,146 @@
package server
import (
"net/http"
"time"
"jarl/internal/auth"
"jarl/internal/engine"
"jarl/internal/model"
"jarl/internal/stats"
"jarl/internal/validate"
)
// exposureFinding is an exposure problem with the fix Jarl can stage for
// it ("" when there is none).
type exposureFinding struct {
validate.Problem
Fix string `json:"fix,omitempty"`
}
type exposureScope struct {
validate.Exposure
RateLimit string `json:"rate_limit"` // off, log-only or enforcing
Findings []exposureFinding `json:"findings"`
}
type exposureReport struct {
Scopes []exposureScope `json:"scopes"`
// Server holds server-wide findings (version, host name, management
// interfaces).
Server []exposureFinding `json:"server"`
// RateLimiting is what rate limiting limited, or would have, in the
// last 24 hours (with query statistics on).
RateLimiting *rateLimitActivity `json:"rate_limiting,omitempty"`
}
type rateLimitActivity struct {
Since string `json:"since"`
Counts []stats.Entry `json:"counts"` // drop, slip, would-drop, would-slip
Networks []stats.Entry `json:"networks"` // client networks, most limited first
Names []stats.Entry `json:"names"` // "name TYPE"
}
// exposure: GET /exposure reports which views are reachable from the
// internet and what the exposure checks found (docs/exposure.md).
func (s *Server) exposure(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.Action{Resource: auth.ResOptions}); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
problems := s.eng.Validate(st)
out := exposureReport{Scopes: []exposureScope{}, Server: []exposureFinding{}}
finding := func(pr validate.Problem, viewID int64) exposureFinding {
_, desc := engine.ExposureFix(st, viewID, pr.Code)
return exposureFinding{Problem: pr, Fix: desc}
}
scopes := map[string]int{}
for _, x := range s.eng.Exposures(st) {
sc := exposureScope{Exposure: x, RateLimit: "off", Findings: []exposureFinding{}}
v := st.Views[x.ViewID]
o := st.FindOption(model.ScopeOptions, 0, "rate-limit")
if v != nil {
if vo := st.FindOption(model.ScopeView, v.ID, "rate-limit"); vo != nil {
o = vo
}
}
if o != nil {
sc.RateLimit = "enforcing"
if validate.LogOnly(o) {
sc.RateLimit = "log-only"
}
}
scopes[x.View] = len(out.Scopes)
out.Scopes = append(out.Scopes, sc)
}
for _, pr := range problems {
if !validate.Ackable(pr.Code) {
continue
}
if pr.ObjectType == "view" || pr.ObjectType == "zone" {
if i, ok := scopes[pr.View]; ok {
out.Scopes[i].Findings = append(out.Scopes[i].Findings, finding(pr, out.Scopes[i].ViewID))
continue
}
}
out.Server = append(out.Server, finding(pr, 0))
}
if s.statsQ != nil && p.Can(auth.Action{Resource: auth.ResStats}) {
ctx, since := r.Context(), 24*time.Hour
a := &rateLimitActivity{Since: "24h"}
a.Counts, _ = s.statsQ.Breakdown(ctx, "rrl", since)
a.Networks, _ = s.statsQ.Top(ctx, stats.KindRateLimited, since, 10)
a.Names, _ = s.statsQ.Top(ctx, stats.KindRateLimitedName, since, 10)
out.RateLimiting = a
}
writeJSON(w, http.StatusOK, out)
return nil
}
// exposureFix: POST /exposure/fix {"view_id": 3, "code": "..."} stages (or
// applies) the fix for a finding, like any other change.
func (s *Server) exposureFix(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in struct {
ViewID int64 `json:"view_id"`
View string `json:"view"`
Code string `json:"code"`
}
if err := decodeJSON(r, &in); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
if in.View != "" {
v := st.ViewByName(in.View)
if v == nil {
return notFound("no view %s", in.View)
}
in.ViewID = v.ID
}
reqs, desc := engine.ExposureFix(st, in.ViewID, in.Code)
if reqs == nil {
return badRequest("there is no automatic fix for %s: what to allow is yours to decide; change the option yourself, or acknowledge the finding", in.Code)
}
return s.mutate(w, r, p, desc, reqs, nil)
}
// exposureAck: POST /exposure/ack {"view": "external", "code": "...",
// "reason": "..."} accepts a finding (unack takes it back).
func (s *Server) exposureAck(ack bool) handler {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.AckInput
if err := decodeJSON(r, &in); err != nil {
return err
}
op := engine.OpExposureUnack
if ack {
op = engine.OpExposureAck
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(op, 0, in)}, nil)
}
}
+1
View File
@@ -297,6 +297,7 @@ var counterMetrics = map[string]struct{ name, label, help string }{
"answer": {"jarl_answers_total", "source", "Answers that were authoritative or not (recursive)."},
"transport": {"jarl_queries_by_transport_total", "transport", "Queries over UDP and TCP."},
"ratelimit": {"jarl_ratelimit_responses_total", "action", "Responses rate limiting dropped or slipped (truncated)."},
"rrl": {"jarl_ratelimit_logged_total", "action", "Rate limited responses from named's log: drop and slip, or would-drop and would-slip in log-only mode."},
"rtt": {"jarl_upstream_responses_by_rtt_total", "rtt", "Responses from other servers during recursion, by round-trip time."},
"dnssec": {"jarl_dnssec_validations_total", "result", "DNSSEC validations of recursive answers by result."},
"cache": {"jarl_cache_lookups_total", "result", "Cache lookups that hit or missed."},
+1 -1
View File
@@ -62,7 +62,7 @@ func engineStatus(k engine.ErrorKind) int {
return http.StatusForbidden
case engine.KindNotFound:
return http.StatusNotFound
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted:
case engine.KindConflict, engine.KindDrift, engine.KindNotAdopted, engine.KindRisk:
return http.StatusConflict
case engine.KindValidation:
return http.StatusUnprocessableEntity
+5 -1
View File
@@ -162,6 +162,10 @@ func (s *Server) routes() {
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/present", s.httpreq(true))
m.HandleFunc("POST "+p+"/acme/httpreq/view/{view}/cleanup", s.httpreq(false))
// Query statistics
m.HandleFunc("GET "+p+"/exposure", s.authed(s.exposure))
m.HandleFunc("POST "+p+"/exposure/fix", s.authed(s.exposureFix))
m.HandleFunc("POST "+p+"/exposure/ack", s.authed(s.exposureAck(true)))
m.HandleFunc("POST "+p+"/exposure/unack", s.authed(s.exposureAck(false)))
m.HandleFunc("GET "+p+"/stats/status", s.authed(s.statsStatus))
m.HandleFunc("GET "+p+"/stats/summary", s.authed(s.statsSummary))
m.HandleFunc("GET "+p+"/stats/top", s.authed(s.statsTop))
@@ -252,7 +256,7 @@ func (s *Server) mutate(w http.ResponseWriter, r *http.Request, p *auth.Principa
writeJSON(w, http.StatusAccepted, api.MutationResponse{Staged: true, ChangesetID: csID, Changeset: raw(res.Changeset), Added: engine.RedactOps(res.Added), Warnings: res.Warnings, Problems: raw(res.Problems)})
return nil
}
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force")}
opts := engine.ApplyOptions{DryRun: queryBool(r, "dry_run"), Force: queryBool(r, "force"), AcceptRisk: queryBool(r, "accept_risk")}
res, err := s.eng.Submit(r.Context(), reqs, p, via(r, p), description, opts)
if err != nil {
s.writeErr(w, r, err, res)