Files
adamandClaude Opus 5.5 bdd7acfd3b 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>
2026-10-12 10:38:56 +01:00

135 lines
4.7 KiB
Go

package stats
import (
"net/netip"
"regexp"
"strings"
"time"
)
// Event is one response named logged.
type Event struct {
Time time.Time
Client netip.Addr
Port string // the client's port: with Client and Name, it ties an RPZ rewrite to its response
View string // "" without views
Name string // lowercase, without the final dot ("." for the root)
Type string // e.g. A, AAAA, PTR
RCode string // e.g. NOERROR, NXDOMAIN
}
// Rewrite is a response policy (RPZ) rewrite named logged: blocking.
type Rewrite struct {
Time time.Time
Client netip.Addr
Port string
View string
Name string // the query name, normalized like Event.Name
Action string // NXDOMAIN, NODATA, PASSTHRU, DROP, ...
Via string // the policy zone entry: OWNER.ZONE
}
// responseRe matches named's response log lines (BIND 9.20, print-time):
//
// 07-Oct-2026 19:46:22.195 client @0x7135... 10.10.0.37#51709 (nas.adbraun.lol): view internal: response: nas.adbraun.lol IN A NOERROR 1 0 1 +E(0)K (10.10.0.2)
//
// The "view NAME: " part is missing when no views are configured, and a
// query signed with a TSIG key has "/key NAME" after the port.
var responseRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?response: (\S+) (\S+) (\S+) ([A-Z0-9]+) `)
// rewriteRe matches named's RPZ rewrite lines (category rpz), logged just
// before the response line of the same query:
//
// 09-Oct-2026 23:49:13.698 client @0x7135... 10.10.0.37#54468 (ads.example): view internal: rpz QNAME NXDOMAIN rewrite ads.example/A/IN via ads.example.hagezi.blocklist.jarl.invalid
var rewriteRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \((.*?)\): (?:view (.+?): )?rpz \S+ (\S+) rewrite \S+ via (\S+)`)
// rateLimitRe matches named's response rate limiting lines (category
// query-errors), when it limits a response or, in log-only mode, would:
//
// 12-Oct-2026 09:56:35.996 client @0x70a2... 127.0.0.2#59296 (www.rrl.test): would rate limit slip response to 127.0.0.0/24 for www.rrl.test IN A (0ce833a4)
var rateLimitRe = regexp.MustCompile(`^(\d{2}-[A-Za-z]{3}-\d{4} \d{2}:\d{2}:\d{2}\.\d{3}) client @0x[0-9a-fA-F]+ (\S+)#(\d+)(?:/key \S+)? \(.*?\): (?:view (.+?): )?(would )?rate limit (drop|slip) (?:\S+ )?response to (\S+) for (\S+) \S+ (\S+)`)
// RateLimit is a response that rate limiting dropped or slipped (sent
// truncated, so a real client retries over TCP), or would have in
// log-only mode.
type RateLimit struct {
Time time.Time
Client netip.Addr
View string
Would bool // log-only: nothing was limited
Action string // drop or slip
Prefix string // the client network named counts, e.g. 192.0.2.0/24
Name string // the query name, normalized like Event.Name
Type string
}
const logTimeLayout = "02-Jan-2006 15:04:05.000"
// ParseRateLimit parses a rate limiting log line.
func ParseRateLimit(line string, loc *time.Location) (rl RateLimit, ok bool) {
m := rateLimitRe.FindStringSubmatch(line)
if m == nil {
return rl, false
}
t, err := time.ParseInLocation(logTimeLayout, m[1], loc)
if err != nil {
return rl, false
}
client, err := netip.ParseAddr(m[2])
if err != nil {
return rl, false
}
return RateLimit{Time: t, Client: client.Unmap(), View: m[4], Would: m[5] != "", Action: m[6], Prefix: m[7], Name: normalizeName(m[8]), Type: m[9]}, true
}
// ParseResponse parses a response log line. ok is false for other lines
// (named also logs to the channel when it starts and rotates files).
func ParseResponse(line string, loc *time.Location) (ev Event, ok bool) {
m := responseRe.FindStringSubmatch(line)
if m == nil {
return ev, false
}
t, err := time.ParseInLocation(logTimeLayout, m[1], loc)
if err != nil {
return ev, false
}
client, err := netip.ParseAddr(m[2])
if err != nil {
return ev, false
}
return Event{
Time: t,
Client: client.Unmap(),
Port: m[3],
View: m[4],
Name: normalizeName(m[5]),
Type: m[7],
RCode: m[8],
}, true
}
// ParseRewrite parses an RPZ rewrite log line.
func ParseRewrite(line string, loc *time.Location) (rw Rewrite, ok bool) {
m := rewriteRe.FindStringSubmatch(line)
if m == nil {
return rw, false
}
t, err := time.ParseInLocation(logTimeLayout, m[1], loc)
if err != nil {
return rw, false
}
client, err := netip.ParseAddr(m[2])
if err != nil {
return rw, false
}
return Rewrite{Time: t, Client: client.Unmap(), Port: m[3], Name: normalizeName(m[4]), View: m[5], Action: m[6], Via: strings.ToLower(m[7])}, true
}
func normalizeName(n string) string {
n = strings.ToLower(n)
if n == "." {
return n
}
return strings.TrimSuffix(n, ".")
}