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
+2
-2
@@ -130,7 +130,7 @@ func submitBatch(c *client.Client, description string, reqs []api.ChangeRequest)
|
||||
}
|
||||
return report(&resp)
|
||||
}
|
||||
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force}, &resp); err != nil {
|
||||
if err := c.Do(ctx(), "POST", "/apply", nil, api.BatchRequest{Description: description, Ops: reqs, DryRun: c.DryRun, Force: c.Force, AcceptRisk: c.AcceptRisk}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
return report(&resp)
|
||||
@@ -349,7 +349,7 @@ func changesetCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp); err != nil {
|
||||
if err := c.Do(ctx(), "POST", "/changesets/"+id+"/apply", nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := report(&resp); err != nil {
|
||||
|
||||
+10
-2
@@ -36,7 +36,7 @@ func viewCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
var clients, dests []string
|
||||
var recursiveOnly, comment string
|
||||
var recursiveOnly, comment, exposure string
|
||||
var adopt, cascade, yes bool
|
||||
viewBody := func(cmd *cobra.Command) map[string]any {
|
||||
body := map[string]any{}
|
||||
@@ -60,6 +60,9 @@ func viewCmd() *cobra.Command {
|
||||
if f.Changed("comment") {
|
||||
body["comment"] = comment
|
||||
}
|
||||
if f.Changed("exposure") {
|
||||
body["exposure"] = exposure
|
||||
}
|
||||
return body
|
||||
}
|
||||
addViewFlags := func(c *cobra.Command) {
|
||||
@@ -67,6 +70,7 @@ func viewCmd() *cobra.Command {
|
||||
c.Flags().StringSliceVar(&dests, "match-destinations", nil, "match-destinations elements")
|
||||
c.Flags().StringVar(&recursiveOnly, "recursive-only", "", "match-recursive-only: yes, no or default")
|
||||
c.Flags().StringVar(&comment, "comment", "", "comment")
|
||||
c.Flags().StringVar(&exposure, "exposure", "", "reachable from the internet: auto (worked out), public or private (docs/exposure.md)")
|
||||
}
|
||||
create := &cobra.Command{
|
||||
Use: "create NAME",
|
||||
@@ -756,7 +760,7 @@ func settingsCmd() *cobra.Command {
|
||||
},
|
||||
}
|
||||
var ns []string
|
||||
var contact, policy string
|
||||
var contact, policy, srvExposure string
|
||||
var ttl, refresh, retry, expire, neg uint32
|
||||
set := &cobra.Command{
|
||||
Use: "set",
|
||||
@@ -777,6 +781,9 @@ func settingsCmd() *cobra.Command {
|
||||
if f.Changed("serial-policy") {
|
||||
body["serial_policy"] = policy
|
||||
}
|
||||
if f.Changed("exposure") {
|
||||
body["exposure"] = srvExposure
|
||||
}
|
||||
for name, v := range map[string]uint32{"ttl": ttl, "refresh": refresh, "retry": retry, "expire": expire, "negative-ttl": neg} {
|
||||
if f.Changed(name) {
|
||||
key := map[string]string{"ttl": "default_ttl", "negative-ttl": "negative_ttl"}[name]
|
||||
@@ -799,6 +806,7 @@ func settingsCmd() *cobra.Command {
|
||||
set.Flags().StringSliceVar(&ns, "ns", nil, "default name servers")
|
||||
set.Flags().StringVar(&contact, "contact", "", "default SOA contact")
|
||||
set.Flags().StringVar(&policy, "serial-policy", "", "date, increment or unixtime")
|
||||
set.Flags().StringVar(&srvExposure, "exposure", "", "without views: is the server reachable from the internet? auto, public or private (docs/exposure.md)")
|
||||
set.Flags().Uint32Var(&ttl, "ttl", 0, "default TTL")
|
||||
set.Flags().Uint32Var(&refresh, "refresh", 0, "SOA refresh")
|
||||
set.Flags().Uint32Var(&retry, "retry", 0, "SOA retry")
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type exposureFinding struct {
|
||||
Severity string `json:"severity"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
View string `json:"view"`
|
||||
Fix string `json:"fix"`
|
||||
}
|
||||
|
||||
type exposureReport struct {
|
||||
Scopes []struct {
|
||||
ViewID int64 `json:"view_id"`
|
||||
View string `json:"view"`
|
||||
Public bool `json:"public"`
|
||||
Override string `json:"override"`
|
||||
Reason string `json:"reason"`
|
||||
RateLimit string `json:"rate_limit"`
|
||||
Findings []exposureFinding `json:"findings"`
|
||||
} `json:"scopes"`
|
||||
Server []exposureFinding `json:"server"`
|
||||
RateLimiting *struct {
|
||||
Since string `json:"since"`
|
||||
Counts []struct {
|
||||
Key string
|
||||
Count int64
|
||||
} `json:"counts"`
|
||||
Networks []struct {
|
||||
Key string
|
||||
Count int64
|
||||
} `json:"networks"`
|
||||
Names []struct {
|
||||
Key string
|
||||
Count int64
|
||||
} `json:"names"`
|
||||
} `json:"rate_limiting"`
|
||||
}
|
||||
|
||||
// exposureScope turns "server" (or "") into the server, anything else
|
||||
// into a view name.
|
||||
func exposureScope(s string) map[string]any {
|
||||
if s == "" || strings.EqualFold(s, "server") {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{"view": s}
|
||||
}
|
||||
|
||||
func exposureCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "exposure",
|
||||
Short: "What is reachable from the internet, and what to harden (docs/exposure.md)",
|
||||
Long: `Shows which views (or the server, without views) are reachable from the
|
||||
internet and why, their rate limiting, and the exposure findings with the
|
||||
fix Jarl can stage for each.`,
|
||||
Example: ` jarl exposure
|
||||
jarl exposure fix external exposure_rate_limit # rate limiting, log-only first
|
||||
jarl exposure fix external exposure_rate_limit_log_only # then start enforcing
|
||||
jarl exposure fix server exposure_version
|
||||
jarl exposure ack external security_open_resolver --reason "public resolver for the club"
|
||||
jarl view update external --exposure public # override the classification`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var r exposureReport
|
||||
if err := c.Get(ctx(), "/exposure", nil, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "VIEW\tEXPOSURE\tRATE LIMIT\tWHY")
|
||||
for _, s := range r.Scopes {
|
||||
name, exp := s.View, "private"
|
||||
if name == "" {
|
||||
name = "(server)"
|
||||
}
|
||||
if s.Public {
|
||||
exp = "public"
|
||||
}
|
||||
if s.Override != "" {
|
||||
exp += " (set)"
|
||||
}
|
||||
rl := s.RateLimit
|
||||
if !s.Public && rl == "off" {
|
||||
rl = "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, exp, rl, s.Reason)
|
||||
}
|
||||
var all []exposureFinding
|
||||
for _, s := range r.Scopes {
|
||||
all = append(all, s.Findings...)
|
||||
}
|
||||
all = append(all, r.Server...)
|
||||
fmt.Fprintln(w)
|
||||
if len(all) == 0 {
|
||||
fmt.Fprintln(w, "No findings.")
|
||||
} else {
|
||||
fmt.Fprintln(w, "SEVERITY\tWHERE\tCODE\tFINDING")
|
||||
for _, f := range all {
|
||||
where := f.View
|
||||
if where == "" {
|
||||
where = "server"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", f.Severity, where, f.Code, f.Message)
|
||||
if f.Fix != "" {
|
||||
fmt.Fprintf(w, "\t\t\tfix: jarl exposure fix %s %s (%s)\n", where, f.Code, f.Fix)
|
||||
}
|
||||
}
|
||||
}
|
||||
if a := r.RateLimiting; a != nil && len(a.Counts) > 0 {
|
||||
fmt.Fprintf(w, "\nRATE LIMITING, LAST %s\n", a.Since)
|
||||
for _, e := range a.Counts {
|
||||
fmt.Fprintf(w, "%s\t%d\n", e.Key, e.Count)
|
||||
}
|
||||
for _, e := range a.Networks {
|
||||
fmt.Fprintf(w, "network %s\t%d\n", e.Key, e.Count)
|
||||
}
|
||||
for _, e := range a.Names {
|
||||
fmt.Fprintf(w, "name %s\t%d\n", e.Key, e.Count)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
fix := &cobra.Command{
|
||||
Use: "fix VIEW|server CODE",
|
||||
Short: "Stage (or apply) the fix Jarl suggests for a finding",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := exposureScope(args[0])
|
||||
body["code"] = args[1]
|
||||
resp, err := c.Mutate(ctx(), "POST", "/exposure/fix", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
var reason string
|
||||
ack := &cobra.Command{
|
||||
Use: "ack VIEW|server CODE --reason TEXT",
|
||||
Short: "Accept a finding on purpose: it stays, as info, with who and why",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := exposureScope(args[0])
|
||||
body["code"], body["reason"] = args[1], reason
|
||||
resp, err := c.Mutate(ctx(), "POST", "/exposure/ack", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
ack.Flags().StringVar(&reason, "reason", "", "why the finding is accepted (required)")
|
||||
unack := &cobra.Command{
|
||||
Use: "unack VIEW|server CODE",
|
||||
Short: "Take an acknowledgement back: the finding warns again",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := exposureScope(args[0])
|
||||
body["code"] = args[1]
|
||||
resp, err := c.Mutate(ctx(), "POST", "/exposure/unack", nil, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(fix, ack, unack)
|
||||
return cmd
|
||||
}
|
||||
+4
-2
@@ -47,6 +47,7 @@ var (
|
||||
flagChangeset int64
|
||||
flagDryRun bool
|
||||
flagForce bool
|
||||
flagRisk bool
|
||||
flagOutput string
|
||||
flagInsecure bool
|
||||
flagCACert string
|
||||
@@ -162,7 +163,7 @@ func newClient() (*client.Client, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
c.DryRun, c.Force = flagDryRun || c.Preflight, flagForce
|
||||
c.DryRun, c.Force, c.AcceptRisk = flagDryRun || c.Preflight, flagForce, flagRisk
|
||||
if !c.Preflight {
|
||||
c.OnTokenExpiry = tokenExpiryWarning(t.Name)
|
||||
}
|
||||
@@ -244,6 +245,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
pf.Int64VarP(&flagChangeset, "changeset", "C", 0, "stage changes into this changeset instead of applying them (env JARL_CHANGESET)")
|
||||
pf.BoolVarP(&flagDryRun, "dry-run", "n", false, "validate and show the changes without applying them")
|
||||
pf.BoolVar(&flagForce, "force", false, "overwrite BIND files that were changed outside Jarl")
|
||||
pf.BoolVar(&flagRisk, "accept-risk", false, "confirm a change that opens a public view up, such as an open resolver (docs/exposure.md)")
|
||||
pf.StringVarP(&flagOutput, "output", "o", "table", "output format: table, json, yaml, id (just the IDs) or field=NAME[,NAME...] (just those fields, e.g. field=view or field=soa.serial)")
|
||||
pf.BoolVar(&flagInsecure, "insecure", false, "don't verify the server's TLS certificate")
|
||||
pf.StringVar(&flagCACert, "ca-cert", "", "PEM file with a CA to trust for the server's certificate")
|
||||
@@ -257,7 +259,7 @@ Exit codes: 0 ok, 1 error, 3 not authenticated or not allowed, 4 not found,
|
||||
}
|
||||
add("dns", zoneCmd(), recordCmd(), viewCmd(), aclCmd(), tsigCmd(), optionCmd(), settingsCmd(), blockCmd(), blocklistCmd(), acmeCmd(), searchCmd(), statsCmd())
|
||||
add("changes", applyCmd(), changesetCmd(), historyCmd())
|
||||
add("admin", statusCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
||||
add("admin", statusCmd(), exposureCmd(), whoamiCmd(), userCmd(), tokenCmd(), auditCmd(), checkCmd(), resyncCmd(), configCmd())
|
||||
setupCompletion(root)
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func perView(c *client.Client, views []string, description string, do func(c *cl
|
||||
return nil
|
||||
}
|
||||
var resp api.MutationResponse
|
||||
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp)
|
||||
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun, "accept_risk": c.AcceptRisk}, &resp)
|
||||
if err != nil || c.DryRun {
|
||||
discard()
|
||||
}
|
||||
|
||||
+6
-2
@@ -128,14 +128,15 @@ export the data.`,
|
||||
|
||||
var limit int
|
||||
top := &cobra.Command{
|
||||
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients]",
|
||||
Use: "top [names|clients|nxdomain|servfail|refused|blocked|blocked-clients|ratelimited|ratelimited-names]",
|
||||
Short: "The most queried names, busiest clients, names with errors, refused clients, blocked names",
|
||||
Example: ` jarl stats top names --since 7d
|
||||
jarl stats top clients --limit 50
|
||||
jarl stats top nxdomain # misconfigured hosts and typos
|
||||
jarl stats top refused # who is asking for what they may not have
|
||||
jarl stats top blocked # what blocking stops most (docs/blocking.md)
|
||||
jarl stats top blocked-clients # the devices blocking stops most`,
|
||||
jarl stats top blocked-clients # the devices blocking stops most
|
||||
jarl stats top ratelimited # client networks rate limiting limits, or would (docs/exposure.md)`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
kind := stats.KindName
|
||||
@@ -147,6 +148,9 @@ export the data.`,
|
||||
if kind == "blocked-client" {
|
||||
kind = stats.KindBlockedClient
|
||||
}
|
||||
if kind == "ratelimited-name" {
|
||||
kind = stats.KindRateLimitedName
|
||||
}
|
||||
}
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"jarl/internal/config"
|
||||
"jarl/internal/engine"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/nscheck"
|
||||
"jarl/internal/porkbun"
|
||||
"jarl/internal/render"
|
||||
"jarl/internal/server"
|
||||
@@ -122,6 +123,7 @@ func openApp(allowMissingConfig bool) (*app, error) {
|
||||
ReloadTimeout: time.Duration(cfg.BIND.ReloadTimeout),
|
||||
NoReload: cfg.BIND.NoReload,
|
||||
Extras: renderExtras(cfg),
|
||||
LocalAddrs: nscheck.LocalAddrs,
|
||||
}, log)
|
||||
eng.Limits = engine.BlocklistLimits{
|
||||
Refresh: time.Duration(cfg.Blocking.Refresh), RetryAfter: time.Hour, Timeout: 2 * time.Minute,
|
||||
|
||||
Reference in new issue
Block a user