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>
143 lines
5.9 KiB
Go
143 lines
5.9 KiB
Go
package engine
|
|
|
|
import (
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
|
|
"jarl/internal/model"
|
|
"jarl/internal/validate"
|
|
)
|
|
|
|
func problemFor(ps []validate.Problem, code, view string) *validate.Problem {
|
|
for i, p := range ps {
|
|
if p.Code == code && (view == "" || p.View == view) {
|
|
return &ps[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestExposure(t *testing.T) {
|
|
env := newEnv(t)
|
|
setupZone(env)
|
|
env.submit(env.admin,
|
|
// An internal network with a global IPv6 prefix is still yours.
|
|
NewRequest(OpACLCreate, 0, ACLInput{Name: model.Ptr("internal-clients"), Elements: &[]string{"2a02:8013:7f3d:10::/64", "192.168.1.0/24"}}),
|
|
optionReq("allow-transfer", "{ 192.0.2.53; }"),
|
|
)
|
|
env.submit(env.admin,
|
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("internal"), MatchClients: &[]string{"internal-clients", "localhost"}, AdoptZones: true}),
|
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("external"), MatchClients: &[]string{"!internal-clients", "any"}}),
|
|
NewRequest(OpViewCreate, 0, ViewInput{Name: model.Ptr("dmz"), MatchClients: &[]string{"any"}, MatchDestinations: &[]string{"10.9.0.53"}}),
|
|
)
|
|
st := env.state()
|
|
exps := map[string]validate.Exposure{}
|
|
for _, x := range env.e.Exposures(st) {
|
|
exps[x.View] = x
|
|
}
|
|
if x := exps["internal"]; x.Public || !strings.Contains(x.Reason, "no catch-all") {
|
|
t.Errorf("internal = %+v", x)
|
|
}
|
|
if x := exps["external"]; !x.Public || x.Reason != "match-clients ends in any" {
|
|
t.Errorf("external = %+v", x)
|
|
}
|
|
if x := exps["dmz"]; x.Public || !strings.Contains(x.Reason, "match-destinations") {
|
|
t.Errorf("dmz = %+v", x)
|
|
}
|
|
|
|
ps := env.e.validate(st)
|
|
for _, code := range []string{"exposure_rate_limit", "exposure_minimal_any"} {
|
|
if problemFor(ps, code, "external") == nil {
|
|
t.Errorf("no %s for external", code)
|
|
}
|
|
if problemFor(ps, code, "internal") != nil || problemFor(ps, code, "dmz") != nil {
|
|
t.Errorf("%s for a private view", code)
|
|
}
|
|
}
|
|
for _, code := range []string{"exposure_version", "exposure_hostname"} {
|
|
if problemFor(ps, code, "") == nil {
|
|
t.Errorf("no %s", code)
|
|
}
|
|
}
|
|
|
|
// The rate limit fix starts in log-only mode and exempts the
|
|
// secondaries.
|
|
ext := st.ViewByName("external")
|
|
reqs, desc := ExposureFix(st, ext.ID, "exposure_rate_limit")
|
|
if len(reqs) != 1 || !strings.Contains(desc, "{ responses-per-second 10; log-only yes; exempt-clients { 192.0.2.53; }; }") {
|
|
t.Fatalf("fix = %v %q", reqs, desc)
|
|
}
|
|
env.submit(env.admin, reqs...)
|
|
ps = env.e.validate(env.state())
|
|
if problemFor(ps, "exposure_rate_limit", "external") != nil {
|
|
t.Error("rate limit warning stays after the fix")
|
|
}
|
|
if p := problemFor(ps, "exposure_rate_limit_log_only", "external"); p == nil || p.Severity != validate.Info {
|
|
t.Errorf("log-only = %+v", p)
|
|
}
|
|
reqs, desc = ExposureFix(env.state(), ext.ID, "exposure_rate_limit_log_only")
|
|
if !strings.Contains(desc, "{ responses-per-second 10; exempt-clients { 192.0.2.53; }; }") {
|
|
t.Errorf("enforce = %q", desc)
|
|
}
|
|
env.submit(env.admin, reqs...)
|
|
if p := problemFor(env.e.validate(env.state()), "exposure_rate_limit_log_only", "external"); p != nil {
|
|
t.Errorf("still log-only: %+v", p)
|
|
}
|
|
|
|
// Opening the server up needs confirming.
|
|
open := optionReqView("allow-recursion", "{ any; }", ext.ID)
|
|
if e := env.submitErr(env.admin, open); e.Kind != KindRisk || !strings.Contains(e.Message, "open resolver") {
|
|
t.Fatalf("open resolver without confirming: %+v", e)
|
|
}
|
|
if _, err := env.e.Submit(env.ctx, []Request{open}, env.admin, "test", "", ApplyOptions{AcceptRisk: true}); err != nil {
|
|
t.Fatalf("with accept risk: %v", err)
|
|
}
|
|
// Acknowledged, it stays as info, with who and why.
|
|
env.submit(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver", Reason: "public resolver for the club"}))
|
|
p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external")
|
|
if p == nil || p.Severity != validate.Info || p.Acknowledged == nil || !strings.Contains(p.Message, "acknowledged by admin on 7 Oct 2026: public resolver for the club") {
|
|
t.Errorf("acknowledged = %+v", p)
|
|
}
|
|
if e := env.submitErr(env.admin, NewRequest(OpExposureAck, 0, AckInput{View: "external", Code: "security_open_resolver"})); e.Kind != KindBadRequest {
|
|
t.Errorf("ack without a reason: %+v", e)
|
|
}
|
|
// Reverting the acknowledgement brings the warning back.
|
|
env.submit(env.admin, NewRequest(OpExposureUnack, 0, AckInput{View: "external", Code: "security_open_resolver"}))
|
|
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p == nil || p.Severity != validate.Warning {
|
|
t.Errorf("after unack = %+v", p)
|
|
}
|
|
// Marking a view private takes it out of the checks.
|
|
env.submit(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("private")}))
|
|
if p := problemFor(env.e.validate(env.state()), "security_open_resolver", "external"); p != nil {
|
|
t.Errorf("private view still checked: %+v", p)
|
|
}
|
|
if e := env.submitErr(env.admin, NewRequest(OpViewUpdate, ext.ID, ViewInput{Exposure: model.Ptr("internet")})); e.Kind != KindBadRequest {
|
|
t.Errorf("bad exposure: %+v", e)
|
|
}
|
|
}
|
|
|
|
// A server without views is public only when named listens on an
|
|
// address that isn't private.
|
|
func TestServerExposure(t *testing.T) {
|
|
env := newEnv(t)
|
|
setupZone(env)
|
|
if x := env.e.Exposures(env.state())[0]; x.Public {
|
|
t.Errorf("no addresses known = %+v", x)
|
|
}
|
|
env.e.cfg.LocalAddrs = func() []netip.Addr {
|
|
return []netip.Addr{netip.MustParseAddr("192.168.1.2"), netip.MustParseAddr("2a02:8013:7f3d:10::53")}
|
|
}
|
|
if x := env.e.Exposures(env.state())[0]; !x.Public || !strings.Contains(x.Reason, "2a02:8013:7f3d:10::53") {
|
|
t.Errorf("GUA address = %+v", x)
|
|
}
|
|
env.submit(env.admin, NewRequest(OpSettingsUpdate, 0, SettingsInput{Exposure: model.Ptr("private")}))
|
|
if x := env.e.Exposures(env.state())[0]; x.Public || x.Override != "private" {
|
|
t.Errorf("marked private = %+v", x)
|
|
}
|
|
}
|
|
|
|
func optionReqView(name, value string, viewID int64) Request {
|
|
return NewRequest(OpOptionCreate, 0, OptionInput{Scope: model.Ptr(model.ScopeView), ScopeID: &viewID, Name: &name, Value: &value})
|
|
}
|