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:
1 parent
cf550bd512
commit
bdd7acfd3b
50 files changed
+2019
-52
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user