diff --git a/README.md b/README.md
index b921ffb..b8322cd 100644
--- a/README.md
+++ b/README.md
@@ -275,15 +275,15 @@ file.
| `acl.groups` | list | empty | evaluated top to bottom, first match wins |
| `acl.groups[].name` / `comment` | string | — | `name` required and unique |
| `acl.groups[].match.cidrs` | list of CIDR | empty | matched against the client address (v4 or v6) |
-| `acl.groups[].match.ptr_suffixes` | list | empty | matched against reverse-DNS names of the client, on a label boundary; a missing trailing dot is added |
+| `acl.groups[].match.ptr_suffixes` | list | empty | matched against reverse-DNS names of the client, on a label boundary; a missing trailing dot is added. `lan.example.` covers the name and everything under it; `.lan.example.` (leading dot) covers only what is under it |
| `acl.groups[].match.tags` | list | empty | labels derived from the reverse name (e.g. `laptop` for `laptop.lan.internal.`) |
| `acl.groups[].match.any` | bool | `false` | catch-all; must be the last group |
-| `acl.groups[].policy` | `allow` \| `deny` \| `block_only` | `allow` | `deny` answers REFUSED and wins over the local zone |
-| `acl.groups[].allow_domains` / `deny_domains` | list | empty | per-group name policy with the blocklist wildcards; `deny_domains` is checked first |
-| `acl.groups[].skip_blocklist` | bool | `false` | the group opts out of blocklist filtering |
+| `acl.groups[].policy` | `allow` \| `deny` \| `block_only` | `allow` | `deny` answers REFUSED and wins over the local zone. **`block_only` behaves exactly like `allow` today**: both resolve normally and both apply the blocklists |
+| `acl.groups[].allow_domains` / `deny_domains` | list | empty | per-group name policy with the blocklist wildcards (`*.x` and `**.x` both cover `x` itself here). **A non-empty `allow_domains` is allowlist-only mode**: every name not in it is blocked for the group; it does not exempt names from the blocklists (that is `skip_blocklist`). `deny_domains` is checked first and wins |
+| `acl.groups[].skip_blocklist` | bool | `false` | the group opts out of blocklist filtering (local zones and address overrides are unaffected) |
| `acl.groups[].skip_cache` | bool | `false` | the group only gets fresh lookups |
| `acl.groups[].rate_limit` | int | inherit | queries/second for this group |
-| `acl.groups[].max_concurrent` | int | `0` (off) | in-flight cap for this client |
+| `acl.groups[].max_concurrent` | int | `0` (off) | in-flight cap for this client; the excess get SERVFAIL |
| `acl.groups[].log_queries` | bool | `false` | log every query from this group regardless of `access_log` selection |
| `acl.groups[].upstreams` | list of addrs | empty | restrict this group to named upstreams; a list that matches nothing falls back to the whole pool |
@@ -521,6 +521,7 @@ is on.
| POST | `/api/domains` | yes | add an inline rule |
| POST | `/api/dns/test` | — | run one query through the live pipeline (`{"name","type"}`) and report the rcode, action, answers, upstream and latency — the console's "is my DNS answering" test, and a failed lookup is a 200 with a result, not a failed request |
| DELETE | `/api/domains/{name…}?action=&list=` | yes | remove a rule |
+| POST | `/api/clients/validate` | — | console only. Check a client group without saving it: `{"group":{…},"test_ip":"…"}` answers `errors` (what blocks a save, with the field), `warnings` (legal but probably unintended: a group buried under a wider one, a rate limit of 0 under a default limit, an allowlist), `notes`, a plain-English `summary`, the group's `position` in the evaluation order, and which group `test_ip` would land in |
| GET | `/api/zones` | — | every local zone with its records, delegations, source files, whether each is an editable cluster object, the record types the console writes, and why the store is stale (`error`) |
| POST | `/api/zones/records` | yes | add a record (`{"zone","name","type","ttl","value"}`; a name without a trailing dot is relative to the zone) |
| PUT | `/api/zones/records` | yes | replace the whole RRset of one name and type |
@@ -580,7 +581,7 @@ docker run --rm -p 53:53/udp -p 53:53/tcp -p 8080:8080 \
The `Dockerfile` builds the console in a Node stage, then a static
`CGO_ENABLED=0` binary with it embedded, and ships it on
`gcr.io/distroless/static-debian12:nonroot`. It declares `VOLUME
-/var/lib/fancy-dns` (cache snapshot, query log, downloaded lists) and copies
+/var/lib/fancy-dns` (cache snapshot, downloaded lists, the saved cluster snapshot) and copies
`deploy/config.example.yaml` plus `deploy/zones/` to `/etc/fancy-dns/`; there
is no `config.yaml` in the image, so its `CMD` (`-config
/etc/fancy-dns/config.yaml`) requires you to mount one.
@@ -626,7 +627,7 @@ Things to know before you apply it:
* **Per-pod PVC sizing.** The StatefulSet uses a 4 Gi `ReadWriteOnce`
template. `cache.size` is an *entry count*, not bytes: a packed answer is
roughly 300–600 bytes on the wire and nearer 1 kB resident, so 500k entries
- lands around 500 MB, and `qlog.max_entries` is the other consumer. The PVC
+ lands around 500 MB, and the downloaded blocklists are the other consumer. The PVC
cannot be shared between replicas, and being a StatefulSet also means an HPA
cannot drive the replica count.
* Probes all hit `/api/healthz` on the admin port, which is unauthenticated on
@@ -771,72 +772,70 @@ Found while writing this file; each is a to-do, not something fixed here.
6. **`internal/config/config.go:229` — `cache.prewarm` is dead.**
`App.prewarm` exists (`internal/app/app.go:220`) but nothing calls it, while
`deploy/config.example.yaml:97` ships `prewarm: []`.
-7. **`internal/config/config.go:358` — `qlog.log_client_names` is dead.**
-8. **`internal/config/config.go:373` — `cluster.peer_sync` is dead**, and
+7. **`internal/config/config.go:373` — `cluster.peer_sync` is dead**, and
**`cluster.advertise`** (`:371`, "the address other instances dial for the
admin API") is only echoed in heartbeats: no code dials a peer.
-9. **`internal/config/config.go:411` — `api.allow_blocklist_write` is dead.**
+8. **`internal/config/config.go:411` — `api.allow_blocklist_write` is dead.**
Only `allow_config_write` is enforced (`internal/api/handlers.go:
handleSettingsPut`).
-10. **`internal/config/config.go:418` — `metrics.path` is dead.** The route is
+9. **`internal/config/config.go:418` — `metrics.path` is dead.** The route is
built from `api.base_path` (`internal/api/api.go:routes`:
`GET metrics`), so `metrics.path: /metrics` in
`deploy/config.example.yaml:196` and in `deploy/k8s/00-config.yaml` has no
effect; the endpoint is `/api/metrics`.
-11. **`internal/config/config.go` — `dns.doh.transport.alt_svc` (`:157`) and
+10. **`internal/config/config.go` — `dns.doh.transport.alt_svc` (`:157`) and
`.mode` (`:156`), `dns.dot.upstream` (`:129`), `dns.edns0.cookie_support`
(`:166`), `dns.edns0.client_subnet` (`:162`), `dns.edns0.ipv4_bits` /
`ipv6_bits` (`:163-164`) are all dead.** `serveDoH` always speaks h2 with an
h2c fallback (no HTTP/3, no Alt-Svc), there is no encrypted-upstream DoT
switch (`upstream.servers[].proto: tls` is the way to get that), and ECS is
forwarded verbatim whenever `dns.edns0.preserve` is on.
-12. **`internal/config/config.go:266` — `blocklist.parallelism` is dead**;
+11. **`internal/config/config.go:266` — `blocklist.parallelism` is dead**;
`RefreshAll` compiles single-threaded.
-13. **`internal/config/config.go` (`ListSource.Timeout`) and
+12. **`internal/config/config.go` (`ListSource.Timeout`) and
`server.timeouts.list_fetch`** — the fetch timeout is never applied:
`blocklist.NewManager` hardcodes a 120 s `http.Client` and
`internal/blocklist/manager.go:fetch` never consults `l.Timeout`.
-14. **`internal/config/config.go` (`UpstreamBootstrap.Refresh`)** — bootstrap
+13. **`internal/config/config.go` (`UpstreamBootstrap.Refresh`)** — bootstrap
names are resolved once per pool rebuild; `refresh` never triggers a
re-resolution.
-15. **`internal/config/config.go` (`Valkey.TLS`)** — `cluster.valkey.tls` is
+14. **`internal/config/config.go` (`Valkey.TLS`)** — `cluster.valkey.tls` is
never applied; `internal/cluster/cluster.go:dial` builds a plain
`redis.Client` with no `TLSConfig`.
-17. **`internal/zonefile/zonefile.go:1-8` and `:238-243` — the package comment
+16. **`internal/zonefile/zonefile.go:1-8` and `:238-243` — the package comment
advertises `$INCLUDE`, the parser rejects it** ("`$INCLUDE … is not
supported; use blocklist.local_zones or a list source instead`"), and
`Options.MaxIncludeDepth` is never set by any caller. Either implement it or
correct the comment and drop the field.
-18. **`Makefile:113` — `make run` claims it runs
+17. **`Makefile:113` — `make run` claims it runs
`deploy/config.example.yaml` "on unprivileged ports", but that file binds
`0.0.0.0:53` and `[::]:53`** (`deploy/config.example.yaml:23-24`).
Unprivileged use needs the same `sed` rewrite the CI smoke test does
(`.github/workflows/ci.yml`, "Smoke test the real binary"), or a port you
can bind.
-20. **`deploy/k8s/20-service.yaml` — the load-balancer comment says to leave
+19. **`deploy/k8s/20-service.yaml` — the load-balancer comment says to leave
the LB health check on the HTTP admin port, but the `fancy-dns-dns`
LoadBalancer Service publishes only 53/UDP, 53/TCP and 853/TCP.** The admin
port is on the separate `fancy-dns-web` ClusterIP Service, which the LB
cannot health-check. Pick a provider with UDP health checks or use DNS-level
failover.
-21. **`deploy/k8s/00-config.yaml` enables `dot` and `doh` against
+20. **`deploy/k8s/00-config.yaml` enables `dot` and `doh` against
`/etc/fancy-dns/tls/tls.crt` while the Secret it mounts ships empty
`tls.crt` / `tls.key`.** Applying the kustomization unchanged leaves both
listeners unable to build a `tls.Config`; a comment says so, but an example
that cannot come up as-is is still a footgun.
-22. **`api.rate_limit.per_minute` defaults to `60` in `config.Default()` but
+21. **`api.rate_limit.per_minute` defaults to `60` in `config.Default()` but
`deploy/config.example.yaml:189` says `600`**, and
- `deploy/config.example.yaml` sets `qlog.log_cached: false` where the default
- is `true`. Harmless, but the example is not a dump of the defaults and
- readers will assume it is.
-23. **`api.auth.mode: apikey` with an emptied `api_keys` list and
+ `deploy/config.example.yaml` is not a dump of the defaults, and readers will
+ assume it is.
+22. **`api.auth.mode: apikey` with an emptied `api_keys` list and
`trust_loopback: true` is indistinguishable from `none`.** `Validate` only
rejects the combination with `trust_loopback: false`; the unsafe case is
reported solely as a warning from `POST /api/settings/validate`.
-24. **`config.Load` does not use `yaml.Decoder.KnownFields`**, so unknown keys
+23. **`config.Load` does not use `yaml.Decoder.KnownFields`**, so unknown keys
are ignored silently. A settled decision (pinning, not changing, the
behaviour); `TestLoadIgnoresUnknownKeys` in `internal/config` holds it.
-25. **RESOLVED — an RPZ record did not cover the name it names.**
+24. **RESOLVED — an RPZ record did not cover the name it names.**
`rpzRule` gave a bare owner name the subdomain form (`*.name`), so
`analytics.example.org.` in a policy zone blocked
`cdn.analytics.example.org.` and left `analytics.example.org.` resolving
@@ -849,7 +848,7 @@ Found while writing this file; each is a to-do, not something fixed here.
`TestExampleRPZZoneCoversTheApexItNames`, including the `rpz-passthru.`
half — the same gap meant a passthru record did not allowlist its own
domain.
-26. **`internal/api/api.go:288-317` — `api.cors_origins: ["*"]` echoes the
+25. **`internal/api/api.go:288-317` — `api.cors_origins: ["*"]` echoes the
request's `Origin` *and* sets `Access-Control-Allow-Credentials: true`.**
That is deliberate (a literal `*` is ignored by browsers when credentials
are involved, so echoing is the only way a wildcard can work with a session
diff --git a/STATUS.md b/STATUS.md
index e1bead2..7d9066a 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -1,657 +1,138 @@
-# fancy-dns — development status and continuation plan
+# fancy-dns — status and continuation plan
-## Console authentication update
+`AGENTS.md` holds the decisions, invariants and working rules. This file holds
+state and the plan. History is in `git log`; the old per-round fix lists were
+removed because each fix is explained in its commit and pinned by a test.
-OIDC login now starts automatically, with server-owned state and PKCE. Go
-exchanges the authorization code, verifies the access token, and stores it in
-an in-memory session addressed by a Secure, HttpOnly, SameSite=Lax cookie.
-Cookie mutations require an exact configured console Origin. Sessions reverify
-the access token and cannot outlive its signed expiry. Logout revokes the local
-session and offers an explicit sign-in button rather than immediately logging
-back in. API keys and bearer tokens remain available for machine clients.
-`api.auth.oidc_redirect_uri` names the registered HTTPS callback; when omitted,
-an HTTPS audience supplies the origin with `/auth/oidc/callback`. The current
-AuthClient already registers that callback, so provider CORS changes are no
-longer needed. Pending logins and sessions are process-local; a restart requires
-sign-in, and multiple replicas need sticky routing (as existing basic sessions do).
-No refresh token is requested; expiry starts another provider sign-in using SSO.
+Last updated after the round that added client-group validation. The cluster-object
+architecture, the separate console and the web UI are deployed.
-Console cleanup removes the unused bootstrap refresh editor, hides Save when
-configuration writes are unavailable, removes script instructions from login,
-and fixes the shell's identity/read-only display by moving it inside its provider.
-Validation: `make check` passes, including race tests; frontend lint and production
-build pass with warnings. These changes have not been deployed.
+## What exists
-Last updated after the round that finished the **server shutdown paths**:
-every transport's ordering with a query in flight is now a test, and it found
-two real bugs — an idle client could hold a shutdown for the whole grace
-period, and a UDP query in flight was neither waited for nor answered. The
-round before that brought the console onto the Go side of the API contract, so
-both halves of that are done. `AGENTS.md` holds the decisions, invariants and
-working rules; this file holds state and the plan.
+Two binaries, one repository:
-## Timeout enforcement update
-
-Foreground queries now enforce `server.timeouts.query` at `engine.Handle`,
-including all failover attempts. Reloads update the limit atomically; earlier
-caller deadlines and cancellation are preserved. `server.timeouts.upstream`
-bounds each complete upstream attempt, with UDP and its TCP truncation retry
-sharing one budget. Per-attempt client copies avoid mutating memoised clients,
-and cancellation closes the private connection to interrupt blocked reads.
-Caller cancellation (including parallel-race losers) does not degrade upstream
-health. An exhausted fallback budget returns an error; an immediate TCP failure
-still preserves the truncated UDP answer for the client to retry.
-
-Regression tests cover silent UDP/TCP/TLS upstreams, active-read cancellation,
-fallback budgeting, failover, configured overall deadlines and hot reload.
-The observed transient `.local` search-expansion incident remains unexplained:
-these timeout defects bound stalls but do not establish why upstream replies
-were absent at the time. The orchestrator's absolute RabbitMQ hostname remains
-the independent search-expansion mitigation.
-
-## Conditional forwarding update
-
-`upstream.forward_zones` now routes an apex and its descendants to dedicated
-upstreams, using the most specific case-insensitive label suffix. Routes reuse
-health/breaker tracking and UDP-to-TCP retry, never fall back to public servers,
-and apply to background cache refreshes too. Authoritative local zones, ACLs,
-and blocklists retain their precedence. Upstream reloads flush the cache; console
-upstream edits preserve the YAML-only routes and do not promote their targets to
-default servers. The Kubernetes config routes `local` to `10.10.0.1:53` over UDP.
-Direct UDP and TCP probes for `fastpi.local` both returned `10.10.11.117`.
-This repository change still needs a rebuilt image and rollout to become live.
-
-## Goal
-
-A lightweight, high-performance recursive DNS server with:
-
-- **Go backend** — sharded LRU cache backed by bbolt, blocklist replication from
- multiple formats, per-client ACLs, query logging, Prometheus metrics.
-- **Secure DNS** — DNS-over-TLS (RFC 7858) and DNS-over-HTTPS (RFC 8484).
- DNSSEC validation was explicitly scoped **out**; AD/CD/extended-RCODE are
- passed through only.
-- **React + Tailwind admin console** embedded in the binary.
-- **Config as zone files + YAML**, hot-reloadable, with **Valkey/Redis** for
- cross-instance sync (chosen over AMQP).
-
-## Architecture decisions (settled with the user)
-
-| Decision | Choice | Why |
+| Binary | Does | Talks to |
|---|---|---|
-| Scope | Full production | clustering, metrics, ACLs, query logs, hot reload, Docker, k8s, CI |
-| Config store | YAML + DNS master files | reviewable/diffable in git |
-| Cluster sync | Valkey (Redis protocol) | simple, mature Go client, easy local dev |
-| Secure DNS | DoT + DoH, no DNSSEC validation | avoids signer/anchor machinery; pass-through only |
-| Hot cache | Sharded LRU in memory, bbolt for snapshots | no I/O on the hot path; warm restart |
-| k8s networking | No hostNetwork, LoadBalancer Service | the server never sets SO_REUSEPORT, so two host-network pods cannot share :53 |
-| Memory | Soft GOMEMLIMIT, cache byte budget | a cgroup kill is an outage; a cache miss is not |
-| Scale-out | StatefulSet, no HPA | an HPA cannot drive a StatefulSet |
-| Unknown YAML keys | Ignore, loudly documented | `KnownFields(true)` was considered and rejected as a behaviour change for anyone carrying extra keys. Pinned by `TestLoadIgnoresUnknownKeys`. |
+| `fancy-dns` | The resolver: UDP/TCP/DoT/DoH, cache, blocklists, ACL groups, local zones, access log, metrics, a read-only admin API. Reads `DNSZone`, `ClientGroup`, `BlockList`, `AddressOverride` and `DomainRule` objects with a watch (`-kube`) and keeps a snapshot so it can start without the cluster. | Upstreams; optionally the Kubernetes API and Valkey. |
+| `fancy-dns-console` | The web UI and the edit API. The only writer of the cluster objects; authenticates (API key, basic, OIDC with PKCE); proxies every other `/api` route to the resolver. Not on the DNS path. | The resolver's admin API and the Kubernetes API. |
-## Package layout
+Operational docs: `README.md` (reference), `deploy/MIGRATION.md` (ConfigMap
+deployment to cluster objects), `deploy/OUTSIDE-CLUSTER.md` (a backup resolver
+and console on a node outside the cluster), `deploy/compose/README.md`
+(file-configured Docker backup).
-```
-cmd/fancy-dns/main.go entrypoint: flags, signals, SIGHUP reload
-internal/
- config/ Config struct, Duration, Size, Validate, atomic Store + hooks,
- ${VAR} environment expansion in Load
- zonefile/ RFC 1035 master-file parser ($TTL/$ORIGIN, parens, serial math)
- blocklist/ Set (exact/sub/root maps), 4 parsers, Manager (fetch+compile+swap)
- cache/ Key, Entry, sharded LRU, TTL policy, serve-stale, bbolt Store
- resolver/ Upstream pool: health, circuit breakers, parallel/failover/random
- acl/ compiled groups, CIDR/PTR/tag matching, per-group domain policy
- localzone/ authoritative data from master files, wildcards, delegations
- engine/ THE QUERY PIPELINE (see below), reverse-PTR cache
- server/ UDP, TCP, DoT, DoH listeners; all funnel into one handler
- qlog/ bbolt query-log ring + live in-memory ring + SSE fanout
- metrics/ Prometheus collectors + rolling time-series rings
- cluster/ Valkey pub/sub, leader lease, divergence detection
- api/ admin REST + SSE, auth, CORS, rate limiting
- app/ wiring, lifecycle, API hook implementations, memory envelope,
- and the response contract the console is written against
- web/ go:embed of the built console
-web/ Vite + React + TS + Tailwind console
-deploy/ example config, example zones, k8s manifests
-```
+## Verified
-### Query pipeline order (engine.Handle)
+`make check` (gofmt, vet, compile, `go test -race ./...`) passes on every
+package. The web console typechecks and `oxlint` reports no errors; the
+remaining warnings are `set-state-in-effect` in `AppShell`, `Login`, `Domains`
+and `Zones` and an unused parameter in `ui/Select.tsx`. The console has no test
+runner, so a UI change is verified by `tsc` and by looking at it.
-```
-validate → ACL deny → per-client concurrency → global concurrency → rate limit
- → group allow/deny domains → local zone → blocklist → cache → upstream
- → cache put → finalize (EDNS0, UDP truncation) → metrics + qlog
-```
+## This round
-Cache is a single shard-local lookup; only a miss touches the network.
+- **Client-group validation.** `POST /api/clients/validate` (console only)
+ checks a group without saving it. It applies the same checks as a save, now
+ reported per field and all at once (`kubesource.CheckGroupAll`,
+ `kubesource.FieldError`), then runs `acl.Analyze`, which explains the group in
+ plain English and warns about legal-but-unintended settings: a group buried
+ under a wider one (and the reverse: a save that buries a later group), CIDRs
+ with host bits or a huge range, an allowlist, `block_only`, a rate limit of 0
+ under a default limit, unknown upstreams, domain patterns that never match,
+ tags with a dot. `test_ip` says which group an address would land in.
+ Tests: `internal/acl/explain_test.go`, `internal/console/validate_test.go`.
+- **Clients page.** Rebuilt around that endpoint: templates for the common
+ cases, sections ("Which devices?", "What happens to their queries?",
+ advanced), live validation with errors next to the field, a "What this group
+ will do" panel, and a try-an-address box. The "Match any client" switch is
+ gone: the console cannot store a catch-all group (`match.any` alone is
+ refused for cluster objects), so it could only produce an error.
+- **The old help text was wrong, in ways that mattered.** It said
+ `allow_domains` "skip the blocklists"; in fact a non-empty list is
+ allowlist-only mode (everything else is blocked). It said `block_only` means
+ "no recursion"; in fact it is identical to `allow`. It suggested `.lan` as a
+ reverse-DNS suffix, which never matched anything (see the next item). It said
+ tags match "ECS/DNS tag metadata"; they are labels of the client's reverse
+ name. README's `acl` table is corrected to match.
+- **`matchPTR` rejected a suffix with a leading dot.** `.lan.` was compared at a
+ label boundary that the leading dot had already consumed, so it matched
+ nothing. A leading dot now means "anything under". Pinned in
+ `TestMatchPTRLabelBoundary`. This changes which clients a group with such a
+ suffix matches (from nobody to the intended ones).
+- **Docs.** This file, `deploy/OUTSIDE-CLUSTER.md`, and removal of the dead
+ `qlog` and `managed_zones` references from README, the Compose config, and a
+ comment in `internal/api/zones.go`.
-## Status
+## Decisions waiting for you
-### Green, end to end
+1. ~~`block_only`.~~ **Decided: removed from the UI.** It did exactly what `allow`
+ does. The API and CRD still accept it so stored objects keep working; the
+ console shows it only on a group that already has it, and the validator warns.
+2. ~~Catch-all group.~~ **Decided: no change.** "Everyone else" is the default policy in
+ the resolver's YAML (`acl.default_policy`). The console does not set it and
+ cluster objects are never catch-alls.
+3. The pinned behaviours below, one at a time.
-`gofmt` clean, `go build ./...` clean, `go vet ./...` clean, and
-`go test -race -count=1 -timeout 300s ./...` green across every package.
+## Pinned, not fixed
-| Package | top-level tests | notes |
-|---|---|---|
-| `internal/acl` | 26 | group compilation, matching, first-group-wins |
-| `internal/api` | 55 (156 cases) | the whole route table, three auth modes, CORS, rate limiting, SSE, settings, error branches, pinned limitations |
-| `internal/app` | 30 (incl. the contract suite) | wiring, hooks, **the API response contract**, integration |
-| `internal/blocklist` | 28 | four parsers, the set, the manager, the shipped example zone |
-| `internal/cache` | 27 | LRU, TTL policy, serve-stale, bbolt round-trip |
-| `internal/cluster` | 20 | Valkey lease, pub/sub, divergence, dead-peer reaping |
-| `internal/config` | 22 | Duration/Size, `${VAR}`, unknown keys, the JSON wire form, the shipped example configs |
-| `internal/engine` | 26 | the pipeline end to end, plus the pins |
-| `internal/localzone` | 18 | wildcards, delegation, CNAME following |
-| `internal/metrics` | 10 | the metric names, which are the contract with the dashboards |
-| `internal/qlog` | 18 | ring, bbolt, top domains, sampling |
-| `internal/resolver` | 10 | pool, breakers, health, policies |
-| `internal/server` | 57 | four listeners, UDP deadline reset, truncation, DoH limits, identity, counters, **shutdown and leak** |
-| `internal/zonefile` | 29 | the master-file parser and the shipped example zone |
-| `web` | 0 | typecheck and `oxlint` clean (0 errors), no test runner |
-| `cmd/fancy-dns` | 0 | exercised by `make smoke` and the CI smoke job instead |
+Each has a test that fails when it changes and says what to do then.
-### The commands that matter
+- **Unknown config keys are silently ignored** (`TestLoadIgnoresUnknownKeys`).
+- **Auth config is frozen at startup.** `api.base_path`, `trusted_proxies` and
+ the authenticator are read once; a reload re-reads CORS only
+ (`TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart`,
+ `TestCORSOriginsAreReadAtHandlerBuild`).
+- **DoH clients behind an Ingress all look like the proxy**, because DoH
+ deliberately ignores `X-Forwarded-For`
+ (`TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader`).
+- **`api.cors_origins: ["*"]` echoes the request's Origin with credentials**
+ (`TestCORSWildcardEchoesTheOrigin`, `TestCORSPreflight`).
+- **A partial bind failure is invisible** to the admin API
+ (`TestStartReportsAPartialFailureButServes`).
+- **A question-less message is answered on UDP and drops the connection on
+ TCP/DoT** (`TestUDPQuestionlessMessageReachesTheEngine`).
+- **`doh.sent` is never bumped** (`TestCountersFollowTheEngineOutcome`).
-- `make check-go` — the inner loop (~1.3 s): gofmt check, vet, compile. Uses
- `go-build`, not `build`, because `build` depends on `web` and shells out to npm.
-- `make check` — `check-go` + the suite. What CI runs.
-- `make smoke` — **new.** Boots the real binary with the console embedded on
- unprivileged ports (the same `sed` rewrite CI uses), then checks `-check`,
- `/api/healthz`, the embedded console, the unauthenticated 401, an
- authenticated `whoami` and `/api/metrics`. Catches a broken embed, a bad
- default and an auth regression in one command.
-- `make web-check` — console typecheck and production build.
-- `make bench` — cache and blocklist benchmarks.
+The README's "Known inconsistencies" section lists more dead config keys and
+stale line references; its line numbers have drifted and have not been
+re-audited.
-### The contract gap is closed on both sides
+## Plan, in order
-The API **response contract** used to be the interesting gap. The console's
-types are hand-written and `apiFetch` ends in `JSON.parse(text) as T`, so
-nothing in the toolchain compared them with the Go structs; a field that lost
-its `json` tag, or a response a struct never had, showed up as an empty table in
-a browser and nowhere else. `internal/app` now has contract tests that assert
-the exact JSON key set of every admin endpoint (see the fix list), and the
-console has been brought onto them — so the two sides agree *today*, but only
-because someone compared them once. The durable fix is still open: see plan
-item 2.
+1. Resolve the decisions above.
+2. **Stop hand-writing the console's types.** `web/src/lib/types.ts` is
+ hand-written and `apiFetch` ends in `JSON.parse(text) as T`; the Go contract
+ tests make the server authoritative today, but only because someone compared
+ them once. Generate the types from the Go structs (or emit an OpenAPI
+ document). `internal/app/dump_shapes_test.go` (`DUMP_SHAPES=1`) is the
+ stopgap.
+3. **Test the console package beyond validation.** `internal/console` has one
+ test file (the validator). The cluster-consistent merge in `handleClients`
+ and `putACL` is the part that has caused lost updates before.
+4. **Decide whether the shutdown grace belongs in config.** `main` hard-codes
+ 15s for `Stop`, of which 250ms is the UDP drain, and the Kubernetes
+ `terminationGracePeriodSeconds` is a second copy of the same number.
+5. **Look at the Zones and Clients pages in a browser.** Neither the Zones page
+ nor this round's Clients page has been seen rendered by the person who wrote
+ it; both are typechecked and built only.
+6. Re-audit README "Known inconsistencies" against the code.
-### The server's shutdown is now pinned behaviour, not folklore
+## Commands
-It was the last part of `internal/server` tested by assumption. Four
-transports, three shutdown shapes (a datagram socket, a stream accept loop with
-configurable idle timeouts, and an `http.Server` that drains politely), and
-every one of them can strand a goroutine a client is holding open. The
-invariants that came out of it are in AGENTS.md; the two bugs it found are in
-the fix list below.
-
-### Decided this round (behaviour changed on purpose, each with a test)
-
-- **An RPZ record did not cover the name it names.** `rpzRule` gave a bare
- owner name the subdomain form (`*.name`), so `analytics.example.org.` in a
- policy zone blocked `cdn.analytics.example.org.` and left
- `analytics.example.org.` resolving normally, and an `rpz-passthru.` record
- did not allowlist its own domain either. RPZ semantics say the record applies
- to the domain and everything under it, so a bare owner now takes the apex
- form `**.name`; an owner written with an explicit `*.` is still strict, so a
- zone that wants subdomain-only behaviour can ask for it. This changes which
- queries get blocked for an existing policy zone — re-read your zones if they
- relied on the old behaviour. `TestExampleRPZZoneCoversTheApexItNames` pins
- both halves, `TestParseRPZ` pins the compiled forms, and the example zone now
- says which half it relies on.
-- **`metrics.OutcomeLocal` had no producer.** The engine reported a local-zone
- answer as `qlog.ActionAllow`, which `outcomeFor` mapped to
- `OutcomeUpstream`, so every locally answered query was counted as an upstream
- one and the dashboard's local column was permanently empty. `Response.Local`
- is now set on the local-zone path and `outcomeFor` consults it first, so a
- local answer publishes `outcome="local"` and is *not* counted as upstream.
- This moved published counters, which is why it was a decision: the README's
- metrics section and the dashboard notes are updated in the same change.
- `TestLocallyAnsweredQueryIsCountedAsLocal` replaces the pin.
-
-## Fixed this round
-
-- **The console could not see local zones at all, so it could not manage
- overrides or delegations.** `blocklist.local_zones` compiled a store that
- nothing could read back: there was no API for a zone, its records, its
- delegations or the file each record came from, and no way to change one
- without editing YAML and reloading. The Domains page is the blocklist's
- inline rules, not local data, so nothing in the console covered this half of
- what the server does. There is now a **Zones** page and the six endpoints
- behind it (`GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`,
- `POST`/`DELETE /api/zones/delegations`, `DELETE /api/zones?zone=`).
-
- The write side is the interesting part, because the only place a zone can be
- changed without a human editing YAML is a file on disk:
-
- - **The console owns `blocklist.managed_zones` and nothing else.** One
- `.zone` per zone, holding the SOA, the apex NS set and the records
- added there. It is compiled by the same `localzone.Load` as the operator's
- files, so a record added from the console is served on the next query with
- no reload. Files in `local_zones` are listed and shown read-only, and the
- config key defaults to empty so a deployment that never sets it cannot have
- zone data rewritten behind its back. `TestZoneWritesNeedAManagedDirectory`
- pins that half.
- - **The writer proves its own output.** A record is validated by the
- project's own master-file parser (so `www` means `www.` and a bare
- CNAME target is completed, the same way it is in a zone file), the file is
- rendered, and that rendering is re-parsed and compared with the records it
- meant to write *before* the file is renamed into place. A round-trip that
- drops a record is a writer bug found there rather than by an operator
- noticing a zone that stopped answering.
- - **A zone that cannot answer is refused, not created.** No SOA means no
- NXDOMAIN or NODATA authority, and no apex NS means nothing to delegate to
- and no server to name, so a new zone has to start with one of the two. The
- SOA's primary follows the apex NS set, the serial moves on every write, and
- a record at or below a delegation is refused — the store answers those names
- as delegated and never reads the file, so it would be data that looks alive
- and never answers.
- - **The engine's gate was the config list, not the store.**
- `engine.applyConfig` only published local-record metrics when
- `blocklist.local_zones` was non-empty, so a zone the console created was
- served but uncounted. The gate is now the store's own record count.
- - `Zone`/`ZoneRecord`/`ZoneDelegation` are type *aliases* in `internal/api`
- rather than copies: one set of json tags, pinned by
- `TestZoneEndpointKeySets` at every level, with the record list filled from a
- real write so the assertion is about a record and not about an envelope.
-
- Verified end to end against a running instance: a record added through the
- API answers authoritatively (`aa`) on the next query, a cut stops the parent
- answering below it, and deleting the zone hands the name back. The console
- page itself was not looked at in a browser in this round — the browser was
- unavailable — so it is typechecked and built, not visually verified.
-
-- **A client that connected and said nothing held the whole shutdown open.**
- The stream accept loop waits for its connections before `Stop` returns, and a
- connection parked in `ReadMsg` with no deadline in front of it only ends when
- the client goes away — `ctx.Err()` is checked *between* reads, so cancelling
- the context cannot reach a read that is already blocked. One idle TCP
- connection (or one DoT connection, with `timeouts.tls` set high) was enough to
- make `Stop` sit there for the caller's entire grace period — 15s in `main` —
- and then return `context.DeadlineExceeded`, which the app reports as
- "shutdown completed with errors". Worse, the cache snapshot the app takes
- right after `Stop` never happened: the orchestrator had already given up. It
- is a shutdown DoS that costs the attacker one connection, and nothing tested
- it. The fix is a read deadline in the past on every accepted connection when
- the context is cancelled, and deliberately *not* a close: a query already
- inside the pipeline is past its read, so its answer still goes out, which is
- the invariant `TestInFlightQueryIsAnsweredAcrossStop` pins.
- `TestStopIsNotBlockedByAnIdleStreamConnection` sets a 60s DoT idle timeout on
- purpose, so a config value cannot hide the regression behind a lucky tick.
-- **A UDP query in flight at `Stop` was lost, and nothing waited for it.** The
- stream paths register their connections with `s.wait`, so `Stop` waits for
- them; `servePacket` did not register at all, so a UDP query already inside
- the pipeline when the socket closed was gone twice over — nothing waited for
- it, and the answer had nowhere to go even once it was ready, because a
- datagram is written to the very socket the query arrived on. The visible
- damage was not the lost answer (the client retries) but the app: it snapshots
- the cache and closes the store the moment `Stop` returns, so a query still in
- the pipeline could be writing to a store that was about to close. Packets are
- now counted in `s.wait`, and the UDP listener's close waits (bounded by
- `udpDrainGrace`, 250ms) for the read loop and the packets it spawned before it
- closes the socket. A shutdown must not spend its grace period on one slow
- upstream, hence the bound: past it the answer is dropped, which is what the
- client would see anyway. Pinned on all four transports by
- `TestInFlightQueryIsAnsweredAcrossStop/{tcp,dot,udp,doh}`.
-- **`Stop` returning did not mean the process was done.** Nothing asserted that
- the server's goroutines were gone, so a shutdown that leaked one was
- indistinguishable from one that did not. `TestStopLeavesNoGoroutineRunningServerCode`
- counts goroutines whose stack still has a frame in this package — not
- `runtime.NumGoroutine()`, which in a test binary is net/http, crypto/tls and
- the race detector talking over each other — and it found the idle-connection
- bug above, since the check is the only way to see a goroutine that has no
- client left to answer.
-
-- **A dead peer stayed in the cluster peer list forever.** `Cluster.seen` was
- only ever added to and deleted from on an explicit `bye`, so a node that
- crashed — or whose Valkey connection died — was reported as a live peer for
- the life of the process, and its stale blocklist digest was flagged as
- divergence forever. `Peers()` now reaps peers unheard for `peer_ttl * 4`
- (four missed heartbeats) and stamps each state with the receiver's own clock,
- so clock skew between two machines can neither reap a healthy peer nor keep a
- dead one. `Discover()` ages out the same way on the publisher's own
- `last_seen` stamp, because the keyed Valkey record outlives a crash: a live
- node keeps refreshing the key's TTL, so a dead node's field stayed readable
- for the life of the fleet. `TestDeadPeerAgesOutOfPeers` (which was failing on
- the tree as handed over — the reaping it asked for did not exist) now passes.
-- **`peer_ttl: 0` would have crashed the process.** `time.NewTicker(PeerTTL/2)`
- panics on a non-positive period, and `EXPIRE key 0` *deletes* the heartbeat
- key, so a config that left `peer_ttl` unset would have taken the process down
- at `Start` (or made every instance believe it is alone in the fleet). Both go
- through `peerTTL()`/`peerDeadline()`, which fall back to the default.
-- **Two cluster tests raced on the wire, not on the product.** A node that
- joined after another's heartbeat was in flight hears about it on the next
- tick, so "b does not see a", read immediately after "a sees b", is a coin
- flip; and `TestStatusReportsDivergentPeers` waited for a digest on the wrong
- node's view of the fleet. Both tests were wrong, not the code.
-- **The partial-bind test only worked unprivileged.**
- `TestStartReportsAPartialFailureButServes` used `127.0.0.1:1` as a port that
- cannot be bound, which is not true in any container with
- `net.ipv4.ip_unprivileged_port_start=0` — it binds, and the test failed with
- both transports up. It now occupies a port with a live listener in the test
- process, which is a deterministic `EADDRINUSE` on every machine and every
- uid.
-- **Four API/console contract breaks, all found by the new contract test:**
- `blocklist.Source` and `SourceMeta` had no `json` tags at all, so the Sources
- table rendered blank rows and its per-row toggle posted `name: undefined`;
- `acl.GroupSummary` was flat where the console reads a nested `match`, so every
- group card read "matches nothing"; `PUT /api/clients` answered 400
- `json: unknown field "ptr_suffixes"` to the payload the console sends, because
- `config.Group` and `config.Match` had yaml tags only; and `PUT /api/upstreams`
- answered 400 `json: unknown field "health"`, because the console round-trips
- the whole server object it read, runtime health fields included. The write
- structs now carry json tags, the health fields are accepted and ignored (the
- server owns them), and `acl.Match` is nested.
-- **A duration on the wire was a nanosecond count.** `config.Duration` had a
- `MarshalYAML` but no `MarshalJSON`, so `bootstraps[].refresh` arrived as
- `30000000000` while the console typed the field as a string, and
- `healthcheck_interval` was a float of seconds in one place and a string in
- another. Durations on the wire are duration strings everywhere now, which is
- the same spelling config.yaml uses; a bare JSON number is still accepted and
- means **seconds**, not nanoseconds. `TestDurationJSONForm` pins all of it and
- says the old behaviour was the bug.
-- **A log row's key set depended on the row.** `api.LogEntry` was `omitempty`
- on `client_group`, `response`, `upstream` and `list`, so a query with no ACL
- group arrived without the key and the console rendered "undefined" where the
- column should be empty. The contract test can only pin one shape if the
- server emits one, so nothing on that struct is `omitempty` any more.
-
-- **Every query from a client that asks for EDNS0 NSID got SERVFAIL.**
- `dns.EDNS0_NSID.Nsid` is a *hex string* in miekg/dns v1.1.73 — `pack`
- hex-decodes it, `unpack` hex-encodes it — and the engine put the literal
- `"fancy-dns"` in it. `hex.DecodeString("fancy-dns")` fails, so the forwarded
- upstream query could not be built, the exchange failed, and the client got
- SERVFAIL. A one-line bug with total blast radius on a documented feature.
- Fixed with `hex.EncodeToString`, and pinned from both sides:
- `TestClientNSIDIsAnswered` (a client asking for NSID gets a real answer, the
- option we forward upstream decodes to our own name, and `dns.hide_identity`
- drops it) and `TestPinnedEDNS0NSIDIsHex` (the library contract, so a future
- bump that changes `Nsid` to raw bytes fails here with an explanation instead
- of silently breaking the feature).
-- **The login rate limiter was applied to every route except the one it exists
- for.** `allowRequest` is the only reader of `limitState.lockedTill` and it ran
- behind `authed`, while the login route was registered through `public`. So N
- bad API keys gave N × 401 and never a 429, and the lockout instead locked the
- caller out of the *rest* of the API — brute-force protection is the one thing
- the auth surface most needs, and it was the one thing that did not work. There
- is now a `limited()` wrapper (the limiter only, no auth) on the login route;
- `allowRequest` stays the single reader of the lockout state. Pinned by
- `TestLoginBruteForceEventuallyHitsTheRateLimiter`,
- `TestLoginLockoutExpiresWithTheClock`, `TestLoginLockoutIsPerIP`,
- `TestLoginLockoutDisabledWithoutFailures` and the `TestAuthedRouteRateLimit*`
- family, so both routes stay honest.
-- **A successful login reset the attacker's request budget.**
- `clearLoginFailures` deleted the whole per-IP `limitState`, which also reset
- the per-minute window, so one valid key in the middle of a brute-force burst
- handed back a fresh budget. It now clears only `failures` and `lockedTill`,
- and `TestSuccessfulLoginDoesNotResetTheRequestWindow` /
- `TestSuccessfulLoginClearsTheFailureCounter` pin both halves.
-- **DoH GET had no size cap.** The POST path is capped by `readCapped` at
- `dns.MaxMsgSize`; the GET path handed `?dns=` straight to a base64 decode and an
- `Unpack`, so the only limit was net/http's `MaxHeaderBytes` — roughly a
- megabyte of attacker-chosen base64 decoded per request, unauthenticated,
- against a 64 KiB cap on the neighbouring path. A DNS message cannot exceed
- 65535 bytes, so the cap is `base64.RawURLEncoding.EncodedLen(dns.MaxMsgSize)`
- and cannot refuse a conforming client. Pinned by
- `TestDoHGETQueryStringOverTheCapIsRefused` (413) and
- `TestDoHServesALargeButLegalQuery` (a message just under the wire limit is
- still served, so the cap cannot be tightened carelessly).
-- **A clean `Stop` sometimes returned "use of closed network connection".** The
- DoH listener has two owners: `Stop`'s close list, and `http.Server.Shutdown`,
- which tracks the same `net.Listener` because `serveDoH` hands it to
- `srv.Serve`. Whichever lost that race made an orderly shutdown look like a
- failed one. Closing an already-closed listener is not a failure, so `Stop` now
- ignores `net.ErrClosed` and only reports a real close error. It reproduced 3
- times in 3 runs with DoH enabled and never with it disabled, which is what
- made it findable; `TestStopIsIdempotent` is the pin.
-- **A per-transport `dropped` counter did not exist.** The DoH drop fix above
- was invisible in the UI: `HandlerStats.Dropped` is an aggregate that cannot
- say which transport dropped, and `TransportStats` had no `Dropped` field at
- all, even though all four transports bump the counter. Added, with the
- reasoning in the field's comment.
-- **A DoH drop was not counted as a drop.**
- `dropped` when `res.Drop`; the DoH path only reached `classify`, which maps
- `drop` to `errors`, so `HandlerStats.Dropped` under-counted DoH. One line, in
- the same position as the other two.
-- **Both shipped zone files matched nothing.** `blocklist.local_zones` and the
- `rpz`-format list source are read with the origin taken from the file's base
- name (`zonefile.OriginFromPath`: `local.example.zone` → `local.`), so the
- relative owner names in the examples were being qualified with that — a
- `*.doubleclick.net` rule blocked `*.doubleclick.net.local.`, and the local
- zone mixed relative owners with absolute ones. An operator copying either file
- got a server that answered nothing and blocked nothing. Both examples now
- spell every name out in full, and
- `TestExampleLocalZoneIsOriginIndependent` (zonefile) plus
- `TestExampleRPZZoneBlocksWhatItSays` (blocklist, through the real
- `parseRPZFiles` path) fail if they are "tidied up" back to relative names.
-- **The shipped configs were never loaded by anything.**
- `deploy/config.example.yaml` and the ConfigMap in `deploy/k8s/00-config.yaml`
- are now loaded through `config.Load` (which ends in `Validate`) and
- spot-checked, including that the k8s `${FANCYDNS_API_KEY}` still expands and
- still *fails* when the Secret is missing, and that the memory envelope matches
- the StatefulSet's 1Gi limit.
-- **The admin API's response shapes had drifted from the console's types** —
- verified against a running binary, not inferred. `blocklist.Source` has no
- `json` tags at all, so the Sources table rendered blank rows and its
- per-row toggle posted `name: undefined`; `acl.GroupSummary` is flat where the
- console expects a nested `match`, so every group card read "matches nothing";
- and **both write endpoints answered 400 to the payload the console actually
- sends** (`json: unknown field "ptr_suffixes"` on `PUT /api/clients`,
- `unknown field "health"` on `PUT /api/upstreams`). The Go side is being fixed
- with contract tests that assert the exact key set of every endpoint; the
- console side is the next piece of work.
-- **The GitHub Actions image job could never tag an image.** It used
- `${{ gitea.sha }}` in a workflow under `.github/`, and the `gitea` context
- only exists on Gitea; the image is also published by
- `.gitea/workflows/build.yaml`, which is the pipeline with registry access. It
- now tags with `github.sha` and only logs in and pushes when this repository
- actually has a registry token, so a GitHub runner builds and stops instead of
- failing.
-- **`api.constantTimeEqual` had no callers**, and the `authenticator` doc
- comment described a constant-time "API key scan" that does not exist. The real
- design is better than the comment (API keys are compared as SHA-256 digests
- through a hash-keyed map, so the plaintext is never stored) — the dead
- function is gone and the comment now describes the code.
-- **`maxGETResponse` was dead code** whose comment claimed a guarantee the
- handler did not provide. Deleted; the DoH response is written as packed.
-
-## Pinned, not fixed (each one has a test that says so out loud)
-
-The standing decision for the round after this one was: **pin, do not change**.
-Every item below is behaviour someone may want to change deliberately; each has
-a test that fails when it changes, and says what to do when it does.
-
-- **Unknown config keys are silently ignored** (a settled decision, see the
- table above). `TestLoadIgnoresUnknownKeys` in `internal/config` also pins the
- asymmetry that makes this surprising: a typo is invisible, while a known key
- with a bad *type* is a startup failure.
-- **Auth configuration is frozen at `New`.** `api.base_path`,
- `api.trusted_proxies` and the authenticator (`auth.mode`, `api_keys`) are
- captured once; a reload re-reads CORS only. Writing a new API key through
- `PUT /api/settings` answers `ok` while the running server keeps the old key.
- `TestPinnedWrittenAuthConfigIsNotAppliedUntilRestart` and
- `TestCORSOriginsAreReadAtHandlerBuild` hold this in place until someone decides
- whether a reload should re-apply it.
-- **DoH clients behind an Ingress all present as the proxy.** `httpRemoteAddr`
- uses the raw `RemoteAddr` and never consults `trusted_proxies`, so every DoH
- client lands in the Ingress's ACL group: fail-closed if that group is
- restrictive, fail-open if it is permissive.
- `TestDoHClientIdentityIsTheProxyNotTheForwardedForHeader` pins it, with the
- reason honouring `X-Forwarded-For` without `trusted_proxies` would break the
- ACL and rate-limit invariants.
-- **`api.cors_origins: ["*"]` echoes the request's `Origin` and sets
- `Access-Control-Allow-Credentials: true`.** Deliberate (a literal `*` is
- ignored by browsers when credentials are involved), but it does mean any
- website can drive the admin API with whatever the browser has cached for that
- host. The preflight also answers 204 for any origin and any path.
- `TestCORSWildcardEchoesTheOrigin` and `TestCORSPreflight` pin all of it.
-- **A partial bind failure is invisible.** `serveDoT`/`serveDoH` record a failed
- bind in an error list only, so `ListenerInfo.Err` is never set and the admin
- API cannot show which address failed.
- `TestStartReportsAPartialFailureButServes` pins that `Start` returns nil
- whenever any listener came up.
-- **A question-less message is answered on UDP and kills the connection on
- TCP/DoT.** `TestUDPQuestionlessMessageReachesTheEngine` pins the UDP half (the
- engine turns it into a FORMERR); the stream path returns instead. Probably
- right for both, but it is an asymmetry worth knowing about.
-- **`doh.sent` is never bumped.** The DoH handler writes its answer through
- `net/http`, so the `sent` counter is only meaningful on UDP/TCP/DoT, and the
- console's transport table shows `sent: 0` for DoH. Pinned as current
- behaviour by `TestCountersFollowTheEngineOutcome` with a comment saying why.
-- **`api.metrics.path` is ignored**
- always `${base_path}/metrics`); `POST /api/blocklists/refresh` returned
- `{"names": null}`; an empty `config_yaml` in the settings form is not valid (it
- decodes onto `Default()`, which enables a blocklist with no sources); `"1Gi"`
- is not a valid size — only `GiB`, `G`, `GB` — even though that is how k8s
- spells it.
-
-## Continuation plan, in order
-
-1. **Decide the rest of the pinned list**, one at a time, each with a changelog
- note: the frozen auth config, the DoH client identity, CORS wildcards, the
- question-less message asymmetry, `doh.sent` never bumped. (The UDP query lost
- at `Stop` left this list when it was fixed.)
-2. **Stop hand-writing the console's types.** The contract tests make the Go
- side authoritative; the cheapest durable answer is generating
- `web/src/lib/types.ts` from the Go structs (or an OpenAPI document emitted by
- the server) so `apiFetch` is typed against something real instead of
- `JSON.parse(text) as T`. `internal/app/dump_shapes_test.go` (`DUMP_SHAPES=1`)
- is the stopgap that already exists.
-3. **Decide whether the shutdown grace belongs in config.** `main` hard-codes
- 15s for the whole `Stop`, and the drain inside it is 250ms of that. A
- Kubernetes `terminationGracePeriodSeconds` and a resolver's own notion of
- "long enough to answer what is in flight" are the same number, and right now
- only one of them is visible. The four-transport invariant is pinned, so
- changing the bound is cheap — but the knobs do not exist yet.
-4. **Read the pinned "not fixed" list in this file for anything that has since
- become untrue.** Each entry claims a behaviour a reader may act on; a claim
- that no longer matches the code is worse than a missing one.
-
-## Design notes worth preserving
-
-- **Cache stores packed wire bytes, not `*dns.Msg`.** A hit costs one `Unpack`
- and yields a private copy, so serving never mutates shared state and TTL
- ageing is free. Same trade dnsmasq makes.
-- **Everything the query path reads is behind an `atomic.Pointer` holder** — ACL,
- blocklist, localzone, and now the upstream pool. Readers load the pointer once
- and work against a value that cannot change under them. Prefer a new immutable
- snapshot over a lock.
-- **Cache invalidation on rule changes flushes the cache** rather than guessing
- which subtrees are affected (`invalidateBlocklistCache` in
- `internal/app/hooks.go`).
-- **API responses share the field names in `internal/app/hooks.go`.** If you
- rename one, grep the frontend too — and now also expect `internal/app`'s
- contract test to fail, which is the point of it.
-- **API keys are compared by SHA-256 hash, constant-time**, and
- `X-Forwarded-For` is honoured *only* from `trusted_proxies`.
-- **The UDP listener resets its read deadline every iteration.** A deadline set
- once expires for good and turns the loop into a spin that never reads another
- packet (caught by `TestBlocklistToggle`).
-- **Shutdown order is: cancel, close every listener, then wait.** The close is
- not interruptible by the caller's context, because releasing the ports is the
- work the next process start needs. Everything after it is interruptible, and
- a cancelled context is expected to be able to cut the *wait* short — which is
- why no step in the shutdown may depend on a client doing anything.
-- **Only the Valkey lease holder fetches lists**, so N instances do not hit a
- mirror N times.
-- **Serve-stale** answers get TTL 1; an expired upstream answer is never cached;
- a truncated answer is never cached (it would be served truncated forever).
-- **`PatternKind.Pattern` is the display form; `WireName` is the wildcard form.**
- The internal `*.` / `**.` prefixes must not leak into the UI, but the API
- needs them to re-add a rule unchanged (the domains list ships `rule` for
- exactly this).
-- **pprof is loopback-only by construction**, not by policy, so no config flag
- can turn it into a network-reachable unauthenticated endpoint.
-- **A master file's origin is its base name**, so a relative owner name in a
- zone is qualified with the file's name, not with the zone you meant. Absolute
- names are the only spelling that survives a rename.
+- `make check-go`: the inner loop (~1s): gofmt, vet, compile (no npm).
+- `make check`: `check-go` plus `go test -race ./...`; what CI runs.
+- `make smoke`: boots the real binary with the console embedded and checks the
+ API, metrics and auth.
+- `make web-check`: console typecheck and production build.
+- `make build`: both binaries with the console embedded (needs npm).
+- `make bench`: cache and blocklist benchmarks.
## Test-hygiene traps
-- `dns.NewRR` needs a TTL column: `dns.NewRR("host.example.com")` does not
- parse. That is a fixture bug, not a product bug.
-- A test that closes a stop channel *after* `wg.Wait()` deadlocks the whole
- package and looks like "the suite hangs". Close first, then wait.
-- Assert on invariants, not on what a concurrent goroutine happened to observe.
- "A reader must have seen address X" is a flake; "every list a reader sees is
- one of the published ones" is a real property and cannot flake.
-- **A test that only dials a stream transport races the accept loop.** Stop can
- close the listener while the connection is still in the backlog, the server
- never accepts it, and any shutdown assertion that needs a live server-side
- goroutine passes for the wrong reason. Exchange one query first, then leave
- the connection alone — that is what `harness.idleStream` is for.
-- **Count leaked goroutines by stack, not by `runtime.NumGoroutine()`.** A test
- binary shares its process with net/http, crypto/tls and the race detector; a
- baseline count is noise. "Does any goroutine still have a frame in this
- package?" is a property of the product, and it needs the caller's own
- goroutine filtered out by id or every run finds exactly one.
-- `tsc -b` replays **cached diagnostics** from `node_modules/.tmp/*.tsbuildinfo`.
- After a large edit, `rm node_modules/.tmp/*.tsbuildinfo`.
-- `vite build` empties `outDir` and **deletes `internal/web/dist/.gitkeep`**.
- Restore it or the directory disappears from git. `//go:embed all:dist` still
- builds while the directory is empty, so a console rebuild in the middle of
- someone else's `go build` is not fatal.
-- **A `TXT` string over 255 bytes does not pack** in miekg/dns v1.1.73 — the
- error is `dns: string exceeded 255 bytes in txt`, and 255 is the exact
- boundary. On a fake upstream's write path the failure is silent and presents
- as a client timeout, so build a multi-kilobyte answer out of many short
- strings and assert the fake's write error before blaming the client.
-- **`dns.EDNS0_NSID.Nsid` is a hex string, not the bytes on the wire.** A plain
- value such as `"fancy-dns"` fails with `dns: overflow packing opt`, and a long
- NSID packs fine (unlike `TXT`). Always hex-encode on the way in.
-- `gofmt -l internal/` is the fastest way to see that a file does not parse.
-- The console has **no test runner at all** — only `tsc -b --noEmit` and
- `oxlint`. A field-name drift between the API and the console is invisible to
- both, which is why the Go side now has contract tests.
-
-## Fixed earlier and worth not re-breaking
-
-A running list, kept because each of these was invisible until it was found:
-
-- `config.ParseSize` ignored the unit multiplier entirely (`512MiB` parsed as
- 512 bytes), so every memory budget in the tree was off by a factor of the
- unit. `size_test.go` pins the units, the shadowing cases, the range and the
- truncating rounding.
-- A cache miss and a stale hit moved only the Prometheus counter and not the
- atomics the console's `Totals` reads, so the Cache page read zero for the
- life of the process.
-- `finalize` — the EDNS0/UDP-size step that turns an oversized answer into a
- question-only TC reply — ran only on the cache-hit path, so a multi-kilobyte
- answer from upstream, from the local zone or from a sinkhole was left as a
- datagram a 512-byte UDP client silently dropped.
-- The serve-stale prefetch gate was a 64-slot channel, so a burst of stale hits
- on one hot name launched up to 64 duplicate upstream exchanges instead of the
- one its own comment promised. It is a per-key latch now.
-- The upstream UDP client read buffer was hard-coded to 1232 while the engine
- advertises `dns.max_udp_size`, so an operator who raised it got SERVFAIL for
- every answer over 1232 bytes.
-- Every relative RDATA name in a master file pointed at the DNS root: the
- qualification pass ran *after* `dns.NewRR`, so `www CNAME host` became
- `CNAME host.`. It is now a token-level pass driven by a table of which RDATA
- positions hold a name.
-- The cache snapshot never round-tripped: `Load` decoded the record header out of
- the *key's* remainder instead of the value, so every restart was a cold cache.
- There is now an `encodeRecord`/`decodeRecord` pair with a version byte and a
- CRC-32C trailer, and `Load` drops expired entries and rewrites the TTL to the
- time actually left.
-- `${VAR}` expansion never worked inside quotes — the only form anyone writes —
- because the reference check sat below the quote branches in the same switch.
- The admin API key was the literal string `"${FANCYDNS_API_KEY}"`.
-- ACL PTR suffix matching crossed label boundaries, so a group scoped to
- `example.com.` also swallowed `ample.com.`; the ACL `*` catch-all was dropped
- from the rendered domain patterns; `Upstream.Success` never closed an open
- breaker; `Exchange` fanned out in parallel even under `policy: failover`; the
- cache byte budget was never fed to the LRU.
-- **`TestRebindUnderConcurrentQueries` was a flake** (2 runs in 10) because it
- asserted a reader had to observe two specific addresses. It now pins the
- copy-on-write invariant itself.
-- CI could not have passed (`go.mod` requires Go 1.27.1, CI and the Dockerfile
- pinned 1.23), the Dockerfile clobbered the console by copying the source tree
- after the bundle, and the k8s set could not have worked (`hostNetwork: true`
- with `replicas: 2` and no SO_REUSEPORT).
-- `$TTL` was ignored by the zone parser; every record got the SOA minimum.
-- Blocklist: leading BOM stripped, `!allow` works, `SourceMeta` exists for a
- rule-only source, `Pattern()` reports the bare name while `WireName()` gives
- the wildcard form, RPZ inherits the zone default TTL, hosts files complain
- about a missing address, adblock handles CRLF and over-long lines.
-- `serveUDP` called `net.ResolveUDPAddr` per packet; the peer already is a
- `*net.UDPAddr`.
+These are in `AGENTS.md` (TXT strings over 255 bytes, `EDNS0_NSID` is hex,
+`dns.NewRR` needs a TTL, closing a stop channel after `wg.Wait()`, counting
+goroutines by stack, `tsc -b` replaying cached diagnostics, `vite build`
+deleting `internal/web/dist/.gitkeep`). One more from this round: a Go test that
+needs a Kubernetes client should use `dynamic/fake` with
+`NewSimpleDynamicClientWithCustomListKinds` and list every GVR in
+`kubesource.allGVRs`, or a `List` panics.
diff --git a/deploy/OUTSIDE-CLUSTER.md b/deploy/OUTSIDE-CLUSTER.md
new file mode 100644
index 0000000..326f288
--- /dev/null
+++ b/deploy/OUTSIDE-CLUSTER.md
@@ -0,0 +1,292 @@
+# Running fancy-dns outside the cluster
+
+This is how to run a second resolver on a machine that is not a pod, for
+example the controller node, as a backup for the in-cluster one. It uses the
+same code and the same cluster objects (`DNSZone`, `ClientGroup`, `BlockList`,
+`AddressOverride`, `DomainRule`), so an edit made in the console reaches both
+resolvers and nobody keeps a second copy of the configuration.
+
+There are two binaries and you probably only want the first:
+
+| Binary | Role | On the controller node |
+|---|---|---|
+| `fancy-dns` | The resolver. Answers DNS, reads the cluster objects with a watch, never writes them. | **Yes**: this is the backup. |
+| `fancy-dns-console` | The web UI. The only thing that writes the objects, and it proxies the rest to a resolver. Not on the DNS path. | Optional. See [the console](#the-console-outside-the-cluster). |
+
+If you meant something else by "the client", say so; the rest of this file
+assumes the resolver.
+
+## How it behaves when the cluster is gone
+
+This is the reason to run it, so know it first.
+
+| Situation | What the resolver does |
+|---|---|
+| Cluster reachable | Serves the cluster objects live and saves a snapshot of them to `/kube-snapshot.json` after every change. |
+| Cluster unreachable at start | Waits 30 seconds, then starts from the saved snapshot and keeps retrying in the background. `GET /api/status` lists `kubernetes: not synced, serving the last saved cluster configuration` under `components.degraded` (and `GET /api/healthz`, which needs no key, reports `degraded`). |
+| Cluster unreachable at start, **never synced before** | Starts with the YAML file only: no cluster zones, address overrides, groups or lists. `GET /api/status` says `not synced and no saved snapshot`. Start it once while the cluster is up, and keep its data directory. |
+| Cluster goes away while running | Keeps serving what it has and reconnects when the API server returns. |
+| The kubeconfig is missing or unreadable | **Refuses to start.** A resolver that cannot build a client at all is a configuration error, not a degraded cluster. Test the kubeconfig (below) before relying on it. |
+
+Because of the snapshot, the backup needs a data directory that survives
+reboots. That is also where the cache snapshot lives, so it is the same
+directory you would have anyway.
+
+Plain DNS to the backup does not depend on the console, on Valkey, or on the
+in-cluster resolver being up.
+
+## 1. Give it read-only access to the objects
+
+Do not copy the cluster's admin kubeconfig onto a DNS server if you can avoid it:
+the resolver only ever needs to read five resource types in one namespace.
+`deploy/k8s/crds/rbac.yaml` already defines that Role (`fancy-dns-objects`).
+Bind a dedicated service account to it and give that account a long-lived token:
+
+```yaml
+# kubectl apply -f this-file.yaml
+apiVersion: v1
+kind: ServiceAccount
+metadata: {name: fancy-dns-backup, namespace: dns}
+---
+apiVersion: v1
+kind: Secret
+metadata:
+ name: fancy-dns-backup-token
+ namespace: dns
+ annotations: {kubernetes.io/service-account.name: fancy-dns-backup}
+type: kubernetes.io/service-account-token
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata: {name: fancy-dns-objects-backup, namespace: dns}
+subjects: [{kind: ServiceAccount, name: fancy-dns-backup, namespace: dns}]
+roleRef: {apiGroup: rbac.authorization.k8s.io, kind: Role, name: fancy-dns-objects}
+```
+
+Build a kubeconfig from it. Run this where you have admin `kubectl`, then copy
+the file to the node as `/etc/fancy-dns/kubeconfig`:
+
+```sh
+SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
+CA=$(kubectl -n dns get secret fancy-dns-backup-token -o jsonpath='{.data.ca\.crt}')
+TOKEN=$(kubectl -n dns get secret fancy-dns-backup-token -o jsonpath='{.data.token}' | base64 -d)
+
+cat > fancy-dns.kubeconfig < +short
+```
+
+`components.degraded` should be empty. The two `dig`s tell you it resolves and
+that it has the cluster's data. Then simulate the outage you bought this for:
+block the API server from the node (or stop the resolver and start it with the
+cluster unreachable), and confirm the same names still answer and
+`components.degraded` now names the snapshot.
+
+Finally point clients at it as a **second** nameserver (DHCP option 6, or the
+router's secondary). It is a backup, not a load balancer: most stub resolvers
+only try the second server after the first times out.
+
+## The console outside the cluster
+
+Optional. You only need it on the node if you want to edit while the in-cluster
+console is unavailable, and it can only save while the API server is reachable,
+since an edit is a write to the cluster. Reads of statistics come from whichever
+resolver you point it at.
+
+It needs a **write** Role, which is a separate account from the resolver's.
+`deploy/k8s/console/console.yaml` defines the Role `fancy-dns-console`; bind a
+second service account to it the same way as above (name it
+`fancy-dns-console-ext`, build `/etc/fancy-dns/console.kubeconfig`).
+
+`/etc/fancy-dns/console.yaml` (see `deploy/console.example.yaml`):
+
+```yaml
+blocklist: {enabled: false}
+api:
+ enabled: true
+ listen: ["127.0.0.1:8081"]
+ base_path: /api
+ ui: true
+ auth:
+ mode: apikey
+ api_keys: ["${FANCYDNS_CONSOLE_KEY}"]
+ trust_loopback: false
+```
+
+`/etc/fancy-dns/console.env`:
+
+```sh
+FANCYDNS_CONSOLE_KEY=
+FANCYDNS_RUNTIME_KEY=
+```
+
+Run it with a unit like the resolver's, minus the port-53 capability:
+
+```ini
+ExecStart=/usr/local/bin/fancy-dns-console \
+ -config /etc/fancy-dns/console.yaml \
+ -runtime-url http://127.0.0.1:8080 \
+ -kubeconfig /etc/fancy-dns/console.kubeconfig \
+ -kube-namespace dns
+```
+
+Keep it on loopback and reach it with an SSH tunnel
+(`ssh -L 8081:127.0.0.1:8081 controller`) or an authenticated TLS reverse proxy.
+The UI can change DNS for every client you have; do not publish it with an API
+key on plain HTTP.
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+|---|---|
+| Service exits immediately: `no usable Kubernetes credentials` | `-kubeconfig` points at a file that is missing, unreadable by the `fancy-dns` user, or malformed. |
+| Starts, `components.degraded` says `not synced` | The API server is unreachable or the token is rejected (`kubectl --kubeconfig … get clientgroups`), or the CRDs are not installed. |
+| `…not synced and no saved snapshot` and cluster data missing | First start with the cluster down. Start it once with the cluster up. Also check the data directory is writable. |
+| `kubernetes: objects rejected` in `components.degraded` | An object failed validation (or this binary is older than the cluster's). The last good version stays in force. Which objects, and why: `jq .problems /var/lib/fancy-dns/kube-snapshot.json`. |
+| Cluster answers differ from the backup's | The upstream/forward-zone config differs (it is YAML, not an object), or the backup's cache holds older answers. `POST /api/cache/flush` on it. |
+| `bind: address already in use` | Something owns port 53; see [Port 53](#port-53). |
+| Console: `cluster: …forbidden` | It is using the read-only kubeconfig; it needs the write Role. |
diff --git a/deploy/compose/README.md b/deploy/compose/README.md
index e579b6f..e22e5b9 100644
--- a/deploy/compose/README.md
+++ b/deploy/compose/README.md
@@ -5,6 +5,11 @@ This Compose deployment follows `deploy/k8s/00-config.yaml` and
of pulling `registry.k6n.net/fancydns`, avoiding a bootstrap dependency on the
cluster registry DNS.
+> **Following the cluster's configuration.** This Compose setup is configured by
+> files and has no link to the cluster's `ClientGroup`, `DNSZone` and other
+> objects. To run a backup resolver that reads them (and keeps serving a saved
+> copy when the cluster is down), see [`../OUTSIDE-CLUSTER.md`](../OUTSIDE-CLUSTER.md).
+
## Prepare and start
From the repository root:
diff --git a/deploy/compose/config.yaml b/deploy/compose/config.yaml
index 34f23e2..6812166 100644
--- a/deploy/compose/config.yaml
+++ b/deploy/compose/config.yaml
@@ -63,7 +63,6 @@ cache:
blocklist:
enabled: true
default_action: nxdomain
- managed_zones: /var/lib/fancy-dns/managed-zones
local_zones: ["/etc/fancy-dns/zones/*.zone"]
lists:
- name: edge-router-x-address-overrides
@@ -83,12 +82,12 @@ acl:
default_policy: allow
groups: []
-qlog:
+# One JSON line per blocked or failed query on stdout (docker logs). The
+# resolver keeps no query log of its own; the metrics count everything.
+access_log:
enabled: true
- path: /var/lib/fancy-dns/queries.db
- max_entries: 200000
- sample_rate: 1.0
log_blocked: true
+ log_errors: true
log_cached: false
api:
diff --git a/internal/acl/acl.go b/internal/acl/acl.go
index 0cdfa2b..d4ffa88 100644
--- a/internal/acl/acl.go
+++ b/internal/acl/acl.go
@@ -327,6 +327,12 @@ func matchPTR(n, suffix string) bool {
if suffix == "" || len(n) < len(suffix) || !strings.HasSuffix(n, suffix) {
return false
}
+ // A suffix written with its own leading dot (".lan.") is the natural way to
+ // say "anything under lan" and the console's own hint suggests it. Its dot
+ // is the label boundary, so there is nothing more to check.
+ if suffix[0] == '.' {
+ return true
+ }
// The suffix has to start a label: it is either the whole name, or the
// character in front of it is a dot.
rest := n[:len(n)-len(suffix)]
diff --git a/internal/acl/acl_test.go b/internal/acl/acl_test.go
index cf7abd8..340b99d 100644
--- a/internal/acl/acl_test.go
+++ b/internal/acl/acl_test.go
@@ -381,6 +381,12 @@ func TestMatchPTRLabelBoundary(t *testing.T) {
{"empty suffix", "host.example.com.", "", false},
{"empty name", "", "example.com.", false},
{"a hyphen is not a label boundary", "host-example.com.", "example.com.", false},
+ // The console suggests ".lan" as a suffix; the leading dot was being
+ // counted as part of the label, so it never matched anything.
+ {"leading-dot suffix matches a name under it", "host.lan.", ".lan.", true},
+ {"leading-dot suffix matches deeper names", "a.b.lan.", ".lan.", true},
+ {"leading-dot suffix does not match the apex", "lan.", ".lan.", false},
+ {"leading-dot suffix still respects the label", "hostlan.", ".lan.", false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
diff --git a/internal/acl/explain.go b/internal/acl/explain.go
new file mode 100644
index 0000000..26a9299
--- /dev/null
+++ b/internal/acl/explain.go
@@ -0,0 +1,414 @@
+package acl
+
+import (
+ "fmt"
+ "net/netip"
+ "strings"
+
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
+)
+
+// Finding is one observation about a group, tied to the field it concerns so
+// an editor can show it next to the input.
+type Finding struct {
+ Field string `json:"field"`
+ Message string `json:"message"`
+}
+
+// Analysis is what Analyze says about a group that is structurally valid.
+//
+// It exists for people who do not know what the settings do. The engine's
+// behaviour is not always what a setting's name suggests (an allow list is an
+// allowlist-only mode, a rate limit of 0 exempts the group from the default),
+// so the analysis states the effective behaviour in plain words and flags the
+// settings that are legal but almost certainly not what was meant.
+type Analysis struct {
+ // Summary is the group in plain English, one sentence per line.
+ Summary []string `json:"summary"`
+ // Warnings are legal settings that probably do not do what was intended.
+ Warnings []Finding `json:"warnings"`
+ // Notes are facts worth knowing that are not mistakes.
+ Notes []Finding `json:"notes"`
+ // Unreachable is set when no client can ever land in the group, because
+ // the groups above it claim everything it selects.
+ Unreachable bool `json:"unreachable"`
+}
+
+// Context is the group under review and everything around it.
+type Context struct {
+ // Groups is the full evaluation order, with the group under review in it.
+ Groups []config.Group
+ // Index is the position of the group under review within Groups.
+ Index int
+ // DefaultRateLimit is acl.default_rate_limit (queries/second/client).
+ DefaultRateLimit int
+ // DefaultPolicy is what a client that matches no group gets.
+ DefaultPolicy string
+ // Upstreams are the configured upstream addresses. Nil means unknown, and
+ // the upstream check is skipped.
+ Upstreams []string
+}
+
+// Analyze explains Groups[Index]. It does not validate: a group that fails
+// validation is reported by the caller's own checks, and Analyze copes with
+// whatever it is handed.
+func Analyze(c Context) Analysis {
+ a := Analysis{Summary: []string{}, Warnings: []Finding{}, Notes: []Finding{}}
+ if c.Index < 0 || c.Index >= len(c.Groups) {
+ return a
+ }
+ g := c.Groups[c.Index]
+ warn := func(field, format string, args ...any) {
+ a.Warnings = append(a.Warnings, Finding{field, fmt.Sprintf(format, args...)})
+ }
+ note := func(field, format string, args ...any) {
+ a.Notes = append(a.Notes, Finding{field, fmt.Sprintf(format, args...)})
+ }
+
+ a.Summary = append(a.Summary, whoSentence(g), c.orderSentence())
+
+ // What the group does.
+ switch g.Policy {
+ case "deny":
+ a.Summary = append(a.Summary, "Every query from these clients is refused (REFUSED). Nothing below applies to them.")
+ for _, ignored := range []struct {
+ field string
+ set bool
+ }{
+ {"allow_domains", len(g.AllowDomains) > 0}, {"deny_domains", len(g.DenyDomains) > 0},
+ {"skip_blocklist", g.SkipBlocklist}, {"skip_cache", g.SkipCache},
+ {"rate_limit", g.RateLimit > 0}, {"max_concurrent", g.MaxConcurrent > 0},
+ {"upstreams", len(g.Upstreams) > 0},
+ } {
+ if ignored.set {
+ note(ignored.field, "Ignored: this group's policy is deny, which refuses the query before anything else is looked at.")
+ }
+ }
+ case "block_only":
+ a.Summary = append(a.Summary, "Queries are resolved normally, and names on a blocklist are blocked.")
+ warn("policy", "block_only currently behaves exactly like allow: both resolve everything and both apply the blocklists. Only deny changes what the group can do. Pick allow unless you are keeping block_only for a reason.")
+ default:
+ a.Summary = append(a.Summary, "Queries are resolved normally, and names on a blocklist are blocked.")
+ }
+
+ if g.Policy != "deny" {
+ a.analyzeBehaviour(c, g, warn, note)
+ }
+ a.analyzeMatch(g, warn, note)
+ a.analyzeDomains(g, warn, note)
+ a.analyzeOrder(c, warn)
+ return a
+}
+
+func (a *Analysis) analyzeBehaviour(c Context, g config.Group, warn, note func(string, string, ...any)) {
+ if len(g.AllowDomains) > 0 {
+ a.Summary = append(a.Summary, fmt.Sprintf("Allowlist mode: ONLY %s can be looked up. Every other name is answered as blocked.", joinShort(g.AllowDomains)))
+ note("allow_domains", "A non-empty allow list turns the group into allowlist-only mode. It does not exempt names from the blocklists; use \"Skip blocklists\" for that.")
+ }
+ if len(g.DenyDomains) > 0 {
+ a.Summary = append(a.Summary, fmt.Sprintf("%s is always blocked for these clients, whatever the blocklists say.", joinShort(g.DenyDomains)))
+ }
+ if g.SkipBlocklist {
+ a.Summary = append(a.Summary, "Blocklists are ignored for these clients: ads and trackers resolve normally.")
+ }
+ if g.SkipCache {
+ a.Summary = append(a.Summary, "Nothing is served from or stored in the cache for these clients; every query goes upstream.")
+ note("skip_cache", "Every query from this group costs an upstream round trip, so it is slower and adds load. Use it for debugging, not as a default.")
+ }
+ switch {
+ case g.RateLimit > 0:
+ a.Summary = append(a.Summary, fmt.Sprintf("Each client address may send %d queries per second; above that, queries are refused.", g.RateLimit))
+ case c.DefaultRateLimit > 0:
+ a.Summary = append(a.Summary, "There is no rate limit for these clients.")
+ warn("rate_limit", "0 means unlimited here, not \"use the default\": this group is exempt from the default limit of %d queries per second per client.", c.DefaultRateLimit)
+ }
+ if g.MaxConcurrent > 0 {
+ a.Summary = append(a.Summary, fmt.Sprintf("A client may have at most %d queries in flight; the excess get SERVFAIL.", g.MaxConcurrent))
+ }
+ if g.LogQueries {
+ a.Summary = append(a.Summary, "Every query from these clients is written to the access log, even when logging is otherwise sampled or limited to blocked queries.")
+ }
+ if len(g.Upstreams) > 0 {
+ a.Summary = append(a.Summary, fmt.Sprintf("Queries go to %s instead of the default upstreams.", joinShort(g.Upstreams)))
+ note("upstreams", "Conditional-forwarding zones (upstream.forward_zones) still take precedence over a group's upstreams.")
+ if c.Upstreams != nil {
+ known := map[string]bool{}
+ for _, u := range c.Upstreams {
+ known[strings.ToLower(u)] = true
+ }
+ var unknown []string
+ for _, u := range g.Upstreams {
+ if !known[strings.ToLower(u)] {
+ unknown = append(unknown, u)
+ }
+ }
+ switch {
+ case len(unknown) == len(g.Upstreams):
+ warn("upstreams", "None of these addresses is a configured upstream, so the setting is ignored and this group uses ALL upstreams. Use an address exactly as it appears on the Upstreams page.")
+ case len(unknown) > 0:
+ warn("upstreams", "%s is not a configured upstream and is ignored.", joinShort(unknown))
+ }
+ }
+ }
+}
+
+func (a *Analysis) analyzeMatch(g config.Group, warn, note func(string, string, ...any)) {
+ if g.Match.Any {
+ warn("match.any", "This group matches every client, so any addresses, suffixes or tags below it are ignored, and so are all groups after it.")
+ }
+ for _, s := range g.Match.CIDRs {
+ p, err := netip.ParsePrefix(strings.TrimSpace(s))
+ if err != nil {
+ continue
+ }
+ if p != p.Masked() {
+ warn("match.cidrs", "%s has host bits set; it is treated as %s.", s, p.Masked())
+ }
+ if (p.Addr().Is4() && p.Bits() < 8) || (!p.Addr().Is4() && p.Bits() < 16) {
+ warn("match.cidrs", "%s is a very wide range: it covers a large share of the address space, probably more than you meant.", s)
+ }
+ }
+ if len(g.Match.PTRSuffixes) > 0 || len(g.Match.Tags) > 0 {
+ note("match", "Reverse-DNS suffixes and tags depend on the client's PTR record. The first queries from a client whose name is not yet cached are matched on address alone, so use a CIDR when the match must be reliable.")
+ }
+ for _, t := range g.Match.Tags {
+ if strings.Contains(t, ".") {
+ warn("match.tags", "Tag %q contains a dot. A tag is a single label of the client's reverse name (\"laptop\" for laptop.lan.example.net), so this never matches. Use a reverse-DNS suffix for a domain.", t)
+ }
+ }
+ for _, s := range g.Match.PTRSuffixes {
+ if strings.ContainsAny(s, "*/ ") {
+ warn("match.ptr_suffixes", "%q is not a plain domain suffix; wildcards and paths are not understood here, so this never matches.", s)
+ }
+ }
+}
+
+func (a *Analysis) analyzeDomains(g config.Group, warn, note func(string, string, ...any)) {
+ lint := func(field string, patterns []string) {
+ for _, raw := range patterns {
+ p := strings.ToLower(strings.TrimSpace(raw))
+ switch {
+ case p == "":
+ case strings.Contains(p, "://") || strings.Contains(p, "/"):
+ warn(field, "%q looks like a URL. Use only the host name, e.g. example.com.", raw)
+ case strings.ContainsAny(p, " \t"):
+ warn(field, "%q contains a space, so it never matches a name.", raw)
+ case strings.HasPrefix(p, "."):
+ warn(field, "%q starts with a dot, which is matched literally and never matches. Write *.%s to cover the name and everything under it.", raw, strings.TrimLeft(p, "."))
+ case p == "*" || p == "**" || p == "*.*":
+ if field == "deny_domains" {
+ warn(field, "%q blocks every name for this group.", raw)
+ } else {
+ warn(field, "%q allows every name, so the allow list restricts nothing.", raw)
+ }
+ case strings.Contains(strings.TrimPrefix(strings.TrimPrefix(p, "**."), "*."), "*"):
+ warn(field, "%q uses a wildcard somewhere other than the start, which is not supported and never matches. Only a leading *. or **. works.", raw)
+ }
+ }
+ }
+ lint("allow_domains", g.AllowDomains)
+ lint("deny_domains", g.DenyDomains)
+
+ if len(g.AllowDomains) > 0 || len(g.DenyDomains) > 0 {
+ note("deny_domains", "*.example.com and **.example.com mean the same thing in these lists: the name itself and everything under it. Write example.com for the exact name only.")
+ }
+ if len(g.AllowDomains) > 0 && len(g.DenyDomains) > 0 {
+ allow, deny := NewDomainSet(g.AllowDomains), NewDomainSet(g.DenyDomains)
+ for _, p := range g.AllowDomains {
+ name := strings.TrimPrefix(strings.TrimPrefix(strings.ToLower(strings.TrimSpace(p)), "**."), "*.")
+ if name != "" && deny.Match(name) && allow.Match(name) {
+ warn("allow_domains", "%q is also covered by the deny list, and deny wins, so it stays blocked.", p)
+ }
+ }
+ }
+}
+
+// analyzeOrder reports groups that shadow this one, and groups this one would
+// shadow. Evaluation is first match wins, so a group whose clients are all
+// claimed higher up is dead weight.
+func (a *Analysis) analyzeOrder(c Context, warn func(string, string, ...any)) {
+ g := c.Groups[c.Index]
+ if g.Match.Any {
+ // The group is the catch-all: the warning on match.any covers it, and
+ // everything after it is reported below.
+ } else if covered, by, all := coverage(g, c.Groups[:c.Index]); all {
+ a.Unreachable = true
+ warn("match", "No client can ever land in this group: everything it selects is already claimed by %q above it, which wins.", by)
+ } else {
+ for _, item := range covered {
+ warn("match", "%s is already claimed by %q above this group, so those clients use that group's settings instead.", item.item, item.by)
+ }
+ }
+ for j := c.Index + 1; j < len(c.Groups); j++ {
+ later := c.Groups[j]
+ with := coverageAll(later, c.Groups[:j])
+ without := coverageAll(later, append(append([]config.Group(nil), c.Groups[:c.Index]...), c.Groups[c.Index+1:j]...))
+ if with && !without {
+ warn("match", "Saving this group makes %q unreachable: it now claims everything that group selects.", later.Name)
+ }
+ }
+}
+
+type claimed struct{ item, by string }
+
+// coverage reports which of g's selectors a group in prior already claims,
+// and whether that is all of them (so g can never match anyone).
+func coverage(g config.Group, prior []config.Group) (items []claimed, first string, all bool) {
+ total := len(g.Match.CIDRs) + len(g.Match.PTRSuffixes) + len(g.Match.Tags)
+ if total == 0 {
+ return nil, "", false
+ }
+ for _, p := range prior {
+ if p.Match.Any {
+ return nil, p.Name, true
+ }
+ }
+ for _, s := range g.Match.CIDRs {
+ c, err := netip.ParsePrefix(strings.TrimSpace(s))
+ if err != nil {
+ continue
+ }
+ for _, p := range prior {
+ if by := coveringCIDR(c, p); by {
+ items = append(items, claimed{s, p.Name})
+ break
+ }
+ }
+ }
+ for _, s := range g.Match.PTRSuffixes {
+ sfx := normSuffix(s)
+ for _, p := range prior {
+ if coveringSuffix(sfx, p) {
+ items = append(items, claimed{s, p.Name})
+ break
+ }
+ }
+ }
+ for _, t := range g.Match.Tags {
+ t = strings.ToLower(strings.TrimSpace(t))
+ for _, p := range prior {
+ if containsFold(p.Match.Tags, t) {
+ items = append(items, claimed{"tag " + t, p.Name})
+ break
+ }
+ }
+ }
+ if len(items) == total {
+ first = items[0].by
+ return items, first, true
+ }
+ return items, "", false
+}
+
+func coverageAll(g config.Group, prior []config.Group) bool {
+ if g.Match.Any {
+ return false
+ }
+ _, _, all := coverage(g, prior)
+ return all
+}
+
+func coveringCIDR(c netip.Prefix, p config.Group) bool {
+ for _, s := range p.Match.CIDRs {
+ q, err := netip.ParsePrefix(strings.TrimSpace(s))
+ if err != nil {
+ continue
+ }
+ if q.Bits() <= c.Bits() && q.Contains(c.Masked().Addr()) {
+ return true
+ }
+ }
+ return false
+}
+
+func coveringSuffix(sfx string, p config.Group) bool {
+ for _, s := range p.Match.PTRSuffixes {
+ earlier := normSuffix(s)
+ // Anything under sfx is under earlier when sfx ends with earlier at a
+ // label boundary; the same test the matcher itself uses.
+ if matchPTR(sfx, earlier) {
+ return true
+ }
+ }
+ return false
+}
+
+func normSuffix(s string) string {
+ s = strings.ToLower(strings.TrimSpace(s))
+ if s != "" && !strings.HasSuffix(s, ".") {
+ s += "."
+ }
+ return s
+}
+
+func containsFold(list []string, want string) bool {
+ for _, s := range list {
+ if strings.EqualFold(strings.TrimSpace(s), want) {
+ return true
+ }
+ }
+ return false
+}
+
+func whoSentence(g config.Group) string {
+ if g.Match.Any {
+ return "Applies to every client."
+ }
+ var parts []string
+ if len(g.Match.CIDRs) > 0 {
+ parts = append(parts, "whose address is in "+joinShort(g.Match.CIDRs))
+ }
+ if len(g.Match.PTRSuffixes) > 0 {
+ parts = append(parts, "whose reverse-DNS name ends in "+joinShort(g.Match.PTRSuffixes))
+ }
+ if len(g.Match.Tags) > 0 {
+ parts = append(parts, "tagged "+joinShort(g.Match.Tags))
+ }
+ if len(parts) == 0 {
+ return "Selects no clients yet."
+ }
+ return "Applies to clients " + strings.Join(parts, ", or ") + "."
+}
+
+func (c Context) orderSentence() string {
+ pos, n := c.Index+1, len(c.Groups)
+ switch {
+ case pos == 1 && n == 1:
+ return "It is the only group; clients that match nothing get the default policy (" + c.defaultPolicy() + ")."
+ case pos == 1:
+ return fmt.Sprintf("It is checked first of %d groups; a client matching it never reaches the others.", n)
+ default:
+ return fmt.Sprintf("It is checked %s of %d groups, after the %d above it; clients matching none get the default policy (%s).", ordinal(pos), n, pos-1, c.defaultPolicy())
+ }
+}
+
+func (c Context) defaultPolicy() string {
+ if c.DefaultPolicy == "" {
+ return "allow"
+ }
+ return c.DefaultPolicy
+}
+
+func ordinal(n int) string {
+ switch n {
+ case 2:
+ return "second"
+ case 3:
+ return "third"
+ }
+ return fmt.Sprintf("number %d", n)
+}
+
+// joinShort lists up to three items and counts the rest.
+func joinShort(items []string) string {
+ clean := make([]string, 0, len(items))
+ for _, s := range items {
+ if s = strings.TrimSpace(s); s != "" {
+ clean = append(clean, s)
+ }
+ }
+ if len(clean) > 3 {
+ return strings.Join(clean[:3], ", ") + fmt.Sprintf(" and %d more", len(clean)-3)
+ }
+ return strings.Join(clean, ", ")
+}
diff --git a/internal/acl/explain_test.go b/internal/acl/explain_test.go
new file mode 100644
index 0000000..b14ced9
--- /dev/null
+++ b/internal/acl/explain_test.go
@@ -0,0 +1,172 @@
+package acl
+
+import (
+ "strings"
+ "testing"
+
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
+)
+
+func named(name, policy string, m config.Match) config.Group {
+ return config.Group{Name: name, Policy: policy, Match: m}
+}
+
+func analyzeAt(idx int, defRate int, groups ...config.Group) Analysis {
+ return Analyze(Context{Groups: groups, Index: idx, DefaultRateLimit: defRate, DefaultPolicy: "allow"})
+}
+
+func has(fs []Finding, field, contains string) bool {
+ for _, f := range fs {
+ if f.Field == field && strings.Contains(f.Message, contains) {
+ return true
+ }
+ }
+ return false
+}
+
+func TestAnalyzeAllowListIsAllowlistMode(t *testing.T) {
+ g := named("kids", "allow", config.Match{CIDRs: []string{"10.0.5.0/24"}})
+ g.AllowDomains = []string{"*.school.example"}
+ a := analyzeAt(0, 0, g)
+ if !has(a.Notes, "allow_domains", "allowlist-only") {
+ t.Errorf("notes = %+v, want the allowlist-only explanation", a.Notes)
+ }
+ if !strings.Contains(strings.Join(a.Summary, " "), "ONLY") {
+ t.Errorf("summary = %v, want it to say only the listed names resolve", a.Summary)
+ }
+}
+
+func TestAnalyzeBlockOnlyIsTheSameAsAllow(t *testing.T) {
+ a := analyzeAt(0, 0, named("g", "block_only", config.Match{CIDRs: []string{"10.0.0.0/24"}}))
+ if !has(a.Warnings, "policy", "exactly like allow") {
+ t.Errorf("warnings = %+v, want the block_only warning", a.Warnings)
+ }
+}
+
+func TestAnalyzeRateLimitZeroExemptsFromTheDefault(t *testing.T) {
+ g := named("g", "allow", config.Match{CIDRs: []string{"10.0.0.0/24"}})
+ if a := analyzeAt(0, 50, g); !has(a.Warnings, "rate_limit", "exempt") {
+ t.Errorf("warnings = %+v, want the exemption warning", a.Warnings)
+ }
+ if a := analyzeAt(0, 0, g); len(a.Warnings) != 0 {
+ t.Errorf("no default limit, yet warnings = %+v", a.Warnings)
+ }
+ g.RateLimit = 20
+ if a := analyzeAt(0, 50, g); has(a.Warnings, "rate_limit", "exempt") {
+ t.Errorf("an explicit limit must not warn about exemption: %+v", a.Warnings)
+ }
+}
+
+func TestAnalyzeDenyIgnoresTheRest(t *testing.T) {
+ g := named("g", "deny", config.Match{CIDRs: []string{"10.0.0.0/24"}})
+ g.RateLimit = 5
+ g.SkipBlocklist = true
+ a := analyzeAt(0, 50, g)
+ if !has(a.Notes, "rate_limit", "Ignored") || !has(a.Notes, "skip_blocklist", "Ignored") {
+ t.Errorf("notes = %+v, want ignored-setting notes", a.Notes)
+ }
+ if has(a.Warnings, "rate_limit", "exempt") {
+ t.Errorf("a denied group has no rate limit to be exempt from: %+v", a.Warnings)
+ }
+}
+
+func TestAnalyzeShadowing(t *testing.T) {
+ wide := named("lan", "allow", config.Match{CIDRs: []string{"10.0.0.0/8"}})
+ narrow := named("cam", "deny", config.Match{CIDRs: []string{"10.1.2.0/24"}})
+
+ a := analyzeAt(1, 0, wide, narrow)
+ if !a.Unreachable || !has(a.Warnings, "match", `"lan"`) {
+ t.Errorf("narrow range after a wider one: unreachable=%v warnings=%+v", a.Unreachable, a.Warnings)
+ }
+
+ // The other order is the useful one and must stay quiet.
+ if a := analyzeAt(0, 0, narrow, wide); a.Unreachable || len(a.Warnings) > 1 {
+ t.Errorf("specific-before-general flagged: unreachable=%v warnings=%+v", a.Unreachable, a.Warnings)
+ }
+
+ // Saving the wide group above the narrow one buries the narrow one.
+ a = analyzeAt(0, 0, wide, narrow)
+ if !has(a.Warnings, "match", `makes "cam" unreachable`) {
+ t.Errorf("warnings = %+v, want the newly-unreachable warning", a.Warnings)
+ }
+}
+
+func TestAnalyzePartialOverlapNamesTheClaimedRange(t *testing.T) {
+ first := named("a", "allow", config.Match{CIDRs: []string{"10.0.0.0/24"}})
+ second := named("b", "allow", config.Match{CIDRs: []string{"10.0.0.128/25", "192.168.0.0/24"}})
+ a := analyzeAt(1, 0, first, second)
+ if a.Unreachable {
+ t.Fatal("one range is still free; the group is reachable")
+ }
+ if !has(a.Warnings, "match", "10.0.0.128/25 is already claimed by \"a\"") {
+ t.Errorf("warnings = %+v", a.Warnings)
+ }
+}
+
+func TestAnalyzeReverseNameAndTagShadowing(t *testing.T) {
+ first := named("a", "allow", config.Match{PTRSuffixes: []string{"lan.example."}, Tags: []string{"laptop"}})
+ second := named("b", "allow", config.Match{PTRSuffixes: []string{".lab.lan.example."}, Tags: []string{"Laptop"}})
+ if a := analyzeAt(1, 0, first, second); !a.Unreachable {
+ t.Errorf("a suffix under an earlier suffix plus the same tag is fully claimed: %+v", a)
+ }
+}
+
+func TestAnalyzeGroupAfterACatchAllIsUnreachable(t *testing.T) {
+ all := named("all", "allow", config.Match{Any: true, CIDRs: []string{"10.0.0.0/8"}})
+ g := named("g", "allow", config.Match{CIDRs: []string{"192.168.0.0/24"}})
+ a := analyzeAt(1, 0, all, g)
+ if !a.Unreachable {
+ t.Error("nothing after a match-any group can be reached")
+ }
+ if b := analyzeAt(0, 0, all, g); !has(b.Warnings, "match.any", "every client") {
+ t.Errorf("warnings = %+v, want the match-any warning", b.Warnings)
+ }
+}
+
+func TestAnalyzeCIDRHygiene(t *testing.T) {
+ a := analyzeAt(0, 0, named("g", "allow", config.Match{CIDRs: []string{"192.168.1.5/24", "0.0.0.0/0"}}))
+ if !has(a.Warnings, "match.cidrs", "host bits") || !has(a.Warnings, "match.cidrs", "very wide") {
+ t.Errorf("warnings = %+v", a.Warnings)
+ }
+}
+
+func TestAnalyzeDomainPatternMistakes(t *testing.T) {
+ g := named("g", "allow", config.Match{CIDRs: []string{"10.0.0.0/24"}})
+ g.AllowDomains = []string{"https://example.com/a", ".example.org", "ads.*.com", "*.fine.example", "*"}
+ g.DenyDomains = []string{"*.fine.example"}
+ a := analyzeAt(0, 0, g)
+ for _, want := range []string{"looks like a URL", "starts with a dot", "not supported", "allows every name", "deny wins"} {
+ if !has(a.Warnings, "allow_domains", want) {
+ t.Errorf("missing allow_domains warning containing %q in %+v", want, a.Warnings)
+ }
+ }
+}
+
+func TestAnalyzeTagWithADotNeverMatches(t *testing.T) {
+ a := analyzeAt(0, 0, named("g", "allow", config.Match{Tags: []string{"lan.example"}}))
+ if !has(a.Warnings, "match.tags", "single label") {
+ t.Errorf("warnings = %+v", a.Warnings)
+ }
+}
+
+func TestAnalyzeUnknownUpstreams(t *testing.T) {
+ g := named("g", "allow", config.Match{CIDRs: []string{"10.0.0.0/24"}})
+ g.Upstreams = []string{"9.9.9.9:53"}
+ a := Analyze(Context{Groups: []config.Group{g}, Upstreams: []string{"1.1.1.1:53"}})
+ if !has(a.Warnings, "upstreams", "uses ALL upstreams") {
+ t.Errorf("warnings = %+v", a.Warnings)
+ }
+ g.Upstreams = []string{"1.1.1.1:53"}
+ if a := Analyze(Context{Groups: []config.Group{g}, Upstreams: []string{"1.1.1.1:53"}}); has(a.Warnings, "upstreams", "") {
+ t.Errorf("a configured upstream warned: %+v", a.Warnings)
+ }
+ if a := Analyze(Context{Groups: []config.Group{g}}); has(a.Warnings, "upstreams", "") {
+ t.Errorf("unknown upstream list must skip the check: %+v", a.Warnings)
+ }
+}
+
+func TestAnalyzeToleratesABadIndex(t *testing.T) {
+ if a := Analyze(Context{Index: 3}); len(a.Summary) != 0 {
+ t.Errorf("summary = %v", a.Summary)
+ }
+}
diff --git a/internal/api/zones.go b/internal/api/zones.go
index ce9adc1..c19b10f 100644
--- a/internal/api/zones.go
+++ b/internal/api/zones.go
@@ -33,8 +33,9 @@ type ZonesResponse struct {
Zones []localzone.Zone `json:"zones"`
// Files are every master file the zones were compiled from.
Files []string `json:"files"`
- // ManagedDir is the directory the API may rewrite, "" when the console
- // has no managed zone directory configured (blocklist.managed_zones).
+ // ManagedDir says where a zone edit is stored, for the console to show:
+ // the console fills it with the Kubernetes namespace its DNSZone objects
+ // live in. The resolver itself leaves it empty, because it cannot write.
ManagedDir string `json:"managed_dir"`
// Writable is whether a write to ManagedDir would succeed.
Writable bool `json:"writable"`
diff --git a/internal/console/console.go b/internal/console/console.go
index 3aadd2a..ba1abd3 100644
--- a/internal/console/console.go
+++ b/internal/console/console.go
@@ -100,6 +100,7 @@ func (c *Console) Handler() (http.Handler, error) {
// Reads that combine the cluster's objects with the resolver's view.
mux.Handle("GET "+base+"/zones", c.api.Protect(http.HandlerFunc(c.handleZones), false))
mux.Handle("GET "+base+"/clients", c.api.Protect(http.HandlerFunc(c.handleClients), false))
+ mux.Handle("POST "+base+"/clients/validate", c.api.Protect(http.HandlerFunc(c.handleClientsValidate), false))
mux.Handle("GET "+base+"/blocklist-sources", c.api.Protect(http.HandlerFunc(c.handleListSources), false))
mux.Handle("PUT "+base+"/blocklist-sources/{name}", c.api.Protect(http.HandlerFunc(c.handleListSourcePut), true))
mux.Handle("DELETE "+base+"/blocklist-sources/{name}", c.api.Protect(http.HandlerFunc(c.handleListSourceDelete), true))
diff --git a/internal/console/hooks.go b/internal/console/hooks.go
index 10df747..1246693 100644
--- a/internal/console/hooks.go
+++ b/internal/console/hooks.go
@@ -56,7 +56,8 @@ type clientsView struct {
config.Group
Source string `json:"source"`
} `json:"groups"`
- DefaultPolicy string `json:"default_policy"`
+ DefaultPolicy string `json:"default_policy"`
+ DefaultRateLimit int `json:"default_rate_limit"`
}
// sameGroup compares two groups by what they configure, so an empty list and a
diff --git a/internal/console/validate.go b/internal/console/validate.go
new file mode 100644
index 0000000..53d8dc9
--- /dev/null
+++ b/internal/console/validate.go
@@ -0,0 +1,193 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/netip"
+
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/api"
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit"
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
+)
+
+// validateRequest is a ClientGroup as the editor has it, before it is saved.
+type validateRequest struct {
+ Group config.Group `json:"group"`
+ // TestIP, when set, says which group an ordinary client at that address
+ // would land in once this group is saved.
+ TestIP string `json:"test_ip,omitempty"`
+}
+
+// validateResponse separates what blocks a save (errors) from what is legal
+// but probably unintended (warnings) and from plain information (notes).
+type validateResponse struct {
+ OK bool `json:"ok"`
+ Errors []acl.Finding `json:"errors"`
+ Warnings []acl.Finding `json:"warnings"`
+ Notes []acl.Finding `json:"notes"`
+ Summary []string `json:"summary"`
+ Unreachable bool `json:"unreachable"`
+ Position int `json:"position"`
+ Total int `json:"total"`
+ Test *validateTestIP `json:"test,omitempty"`
+}
+
+type validateTestIP struct {
+ IP string `json:"ip"`
+ Valid bool `json:"valid"`
+ Group string `json:"group"`
+ Default bool `json:"default"`
+ Note string `json:"note"`
+}
+
+// handleClientsValidate checks a group without saving it. It applies the same
+// checks the save applies (kubesource.CheckGroup, the object-name rule) and
+// then says what the group would do and what is probably wrong with it, in the
+// context of the groups that already exist. A group that is not already saved
+// is placed last, which is where a save puts it.
+func (c *Console) handleClientsValidate(w http.ResponseWriter, r *http.Request) {
+ var in validateRequest
+ if err := api.DecodeJSON(r, &in); err != nil {
+ api.WriteError(w, http.StatusBadRequest, "malformed request body")
+ return
+ }
+ current, meta, err := c.currentGroups(r.Context())
+ if err != nil {
+ api.WriteError(w, http.StatusBadGateway, err.Error())
+ return
+ }
+
+ out := validateResponse{Errors: []acl.Finding{}, Warnings: []acl.Finding{}, Notes: []acl.Finding{}, Summary: []string{}}
+ name := in.Group.Name
+ if err := kubeedit.CheckGroupName(name); err != nil {
+ out.Errors = append(out.Errors, acl.Finding{Field: "name", Message: err.Error()})
+ }
+ spec := kubeedit.SpecFromGroup(in.Group, 0)
+ for _, fe := range kubesource.CheckGroupAll(name, spec) {
+ out.Errors = append(out.Errors, acl.Finding{Field: fe.Field, Message: fe.Msg})
+ }
+ if have, ok := meta.fileGroups[name]; ok && !sameGroup(have, in.Group) {
+ out.Errors = append(out.Errors, acl.Finding{Field: "name", Message: "A group with this name is defined in the resolver's config file and cannot be changed here. Pick another name."})
+ }
+
+ groups, idx := place(current, in.Group)
+ out.Position, out.Total = idx+1, len(groups)
+ out.OK = len(out.Errors) == 0
+
+ // The analysis is useful even for a group that cannot be saved yet (it
+ // explains what the typed-so-far group would do), but it only makes sense
+ // once the group selects something.
+ an := acl.Analyze(acl.Context{
+ Groups: groups, Index: idx,
+ DefaultRateLimit: meta.defaultRateLimit, DefaultPolicy: meta.defaultPolicy,
+ Upstreams: meta.upstreams,
+ })
+ out.Summary, out.Warnings, out.Notes, out.Unreachable = an.Summary, an.Warnings, an.Notes, an.Unreachable
+
+ if in.TestIP != "" {
+ out.Test = testIP(in.TestIP, groups, meta.defaultPolicy)
+ }
+ api.WriteJSON(w, http.StatusOK, out)
+}
+
+// place puts g in the list: in its own slot when a group of that name exists,
+// otherwise last, which is where a save adds it.
+func place(list []config.Group, g config.Group) ([]config.Group, int) {
+ out := append([]config.Group(nil), list...)
+ for i := range out {
+ if out[i].Name == g.Name && g.Name != "" {
+ out[i] = g
+ return out, i
+ }
+ }
+ return append(out, g), len(out)
+}
+
+func testIP(raw string, groups []config.Group, defaultPolicy string) *validateTestIP {
+ res := &validateTestIP{IP: raw}
+ ip, err := netip.ParseAddr(raw)
+ if err != nil {
+ res.Note = "Not an IP address."
+ return res
+ }
+ res.Valid = true
+ d := acl.Compile(config.ACL{DefaultPolicy: defaultPolicy, Groups: groups}).Match(acl.Client{IP: ip})
+ res.Group, res.Default = d.Group, d.Default
+ switch {
+ case d.Default:
+ res.Note = "No group matches this address by IP; it gets the default policy (" + string(d.Policy) + ")."
+ default:
+ res.Note = "Matched on address. Clients can also match by reverse-DNS name or tag, which cannot be tested here."
+ }
+ return res
+}
+
+type groupsMeta struct {
+ fileGroups map[string]config.Group
+ defaultPolicy string
+ defaultRateLimit int
+ upstreams []string
+}
+
+// currentGroups is the evaluation order the resolver will use, made consistent
+// with the cluster: the same merge handleClients does, as typed groups.
+func (c *Console) currentGroups(ctx context.Context) ([]config.Group, groupsMeta, error) {
+ meta := groupsMeta{fileGroups: map[string]config.Group{}}
+ var view clientsView
+ if err := c.opt.Runtime.Get(ctx, "/api/clients", &view); err != nil {
+ return nil, meta, err
+ }
+ objs, err := c.opt.Editor.Groups(ctx)
+ if err != nil {
+ return nil, meta, errors.New("cluster: " + err.Error())
+ }
+ meta.defaultPolicy, meta.defaultRateLimit = view.DefaultPolicy, view.DefaultRateLimit
+
+ inCluster := map[string]bool{}
+ for _, o := range objs {
+ inCluster[o.Name] = true
+ }
+ var out []config.Group
+ seen := map[string]bool{}
+ for _, g := range view.Groups {
+ switch g.Source {
+ case "history":
+ continue
+ case "kubernetes":
+ if !inCluster[g.Name] {
+ continue
+ }
+ default:
+ meta.fileGroups[g.Name] = g.Group
+ }
+ seen[g.Name] = true
+ out = append(out, g.Group)
+ }
+ for _, o := range objs {
+ if !seen[o.Name] {
+ g := config.Group{Name: o.Name, Comment: o.Spec.Comment, Match: o.Spec.Match, Policy: o.Spec.Policy,
+ AllowDomains: o.Spec.AllowDomains, DenyDomains: o.Spec.DenyDomains, SkipBlocklist: o.Spec.SkipBlocklist,
+ SkipCache: o.Spec.SkipCache, RateLimit: o.Spec.RateLimit, MaxConcurrent: o.Spec.MaxConcurrent,
+ LogQueries: o.Spec.LogQueries, Upstreams: o.Spec.Upstreams}
+ out = append(out, g)
+ }
+ }
+
+ // Upstreams are a nicety for one warning; an unreachable or older resolver
+ // must not make validation fail.
+ var ups struct {
+ Servers []struct {
+ Addr string `json:"addr"`
+ } `json:"servers"`
+ }
+ if err := c.opt.Runtime.Get(ctx, "/api/upstreams", &ups); err == nil {
+ meta.upstreams = []string{}
+ for _, s := range ups.Servers {
+ meta.upstreams = append(meta.upstreams, s.Addr)
+ }
+ }
+ return out, meta, nil
+}
diff --git a/internal/console/validate_test.go b/internal/console/validate_test.go
new file mode 100644
index 0000000..8df4f34
--- /dev/null
+++ b/internal/console/validate_test.go
@@ -0,0 +1,181 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ dynfake "k8s.io/client-go/dynamic/fake"
+
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubeedit"
+ "git.k8s-git-k6n.net/mats/fancy-dns/internal/kubesource"
+)
+
+// validator builds a console whose cluster holds the given groups and whose
+// resolver reports one group of its own, defined in its config file.
+func validator(t *testing.T, saved ...config.Group) *Console {
+ t.Helper()
+ cl := dynfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(),
+ map[schema.GroupVersionResource]string{
+ kubesource.ZoneGVR: "DNSZoneList", kubesource.GroupGVR: "ClientGroupList",
+ kubesource.ListGVR: "BlockListList", kubesource.OverrideGVR: "AddressOverrideList",
+ kubesource.RuleGVR: "DomainRuleList",
+ })
+ ed := kubeedit.New(cl, "dns")
+ if len(saved) > 0 {
+ if _, err := ed.SyncGroups(context.Background(), saved, nil, "test"); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/clients":
+ groups := []map[string]any{{
+ "name": "from-file", "policy": "allow", "source": "",
+ "match": map[string]any{"cidrs": []string{"172.16.0.0/12"}},
+ }}
+ for _, g := range saved {
+ groups = append(groups, map[string]any{"name": g.Name, "policy": g.Policy, "source": "kubernetes", "match": g.Match})
+ }
+ json.NewEncoder(w).Encode(map[string]any{"groups": groups, "default_policy": "allow", "default_rate_limit": 50})
+ case "/api/upstreams":
+ json.NewEncoder(w).Encode(map[string]any{"servers": []map[string]any{{"addr": "1.1.1.1:53"}}})
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(resolver.Close)
+ rt, err := NewRuntime(resolver.URL, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return &Console{opt: Options{Runtime: rt, Editor: ed}, log: slog.Default()}
+}
+
+func validate(t *testing.T, c *Console, body string) validateResponse {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ c.handleClientsValidate(rec, httptest.NewRequest(http.MethodPost, "/api/clients/validate", strings.NewReader(body)))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d: %s", rec.Code, rec.Body)
+ }
+ var out validateResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("%v: %s", err, rec.Body)
+ }
+ return out
+}
+
+func hasField(list []string, f string) bool {
+ for _, s := range list {
+ if s == f {
+ return true
+ }
+ }
+ return false
+}
+
+func errFields(r validateResponse) []string {
+ var out []string
+ for _, e := range r.Errors {
+ out = append(out, e.Field)
+ }
+ return out
+}
+
+func TestValidateReportsEveryErrorAtOnce(t *testing.T) {
+ c := validator(t)
+ r := validate(t, c, `{"group":{"name":"Bad Name","policy":"sometimes","match":{"cidrs":["10.0.0.0/33"]},"rate_limit":-1}}`)
+ if r.OK {
+ t.Fatal("a group with four problems validated")
+ }
+ for _, want := range []string{"name", "policy", "match.cidrs", "rate_limit"} {
+ if !hasField(errFields(r), want) {
+ t.Errorf("no error for %q; got %+v", want, r.Errors)
+ }
+ }
+}
+
+func TestValidateAcceptsAGoodGroupAndExplainsIt(t *testing.T) {
+ c := validator(t)
+ r := validate(t, c, `{"group":{"name":"kids","policy":"allow","match":{"cidrs":["10.0.5.0/24"]},"allow_domains":["*.school.example"]}}`)
+ if !r.OK || len(r.Errors) != 0 {
+ t.Fatalf("errors: %+v", r.Errors)
+ }
+ if r.Position != 2 || r.Total != 2 {
+ t.Errorf("a new group goes last: position %d of %d", r.Position, r.Total)
+ }
+ if len(r.Summary) < 3 {
+ t.Errorf("summary = %v", r.Summary)
+ }
+ // 0 rate limit under a default of 50 is the surprise worth flagging.
+ found := false
+ for _, w := range r.Warnings {
+ found = found || w.Field == "rate_limit"
+ }
+ if !found {
+ t.Errorf("warnings = %+v, want the rate-limit exemption", r.Warnings)
+ }
+}
+
+func TestValidateEditingKeepsThePosition(t *testing.T) {
+ a := config.Group{Name: "a", Policy: "allow", Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}}
+ b := config.Group{Name: "b", Policy: "allow", Match: config.Match{CIDRs: []string{"192.168.0.0/16"}}}
+ c := validator(t, a, b)
+ r := validate(t, c, `{"group":{"name":"a","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}}}`)
+ if r.Position != 2 || r.Total != 3 { // from-file, a, b
+ t.Errorf("position %d of %d", r.Position, r.Total)
+ }
+}
+
+func TestValidateRefusesAFileGroupName(t *testing.T) {
+ c := validator(t)
+ r := validate(t, c, `{"group":{"name":"from-file","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}}}`)
+ if r.OK || !hasField(errFields(r), "name") {
+ t.Errorf("a group the resolver's file owns validated: %+v", r)
+ }
+}
+
+func TestValidateFlagsAGroupBuriedUnderAWiderOne(t *testing.T) {
+ wide := config.Group{Name: "wide", Policy: "allow", Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}}
+ c := validator(t, wide)
+ r := validate(t, c, `{"group":{"name":"narrow","policy":"deny","match":{"cidrs":["10.1.0.0/16"]}}}`)
+ if !r.OK || !r.Unreachable {
+ t.Errorf("ok=%v unreachable=%v warnings=%+v", r.OK, r.Unreachable, r.Warnings)
+ }
+}
+
+func TestValidateTestIP(t *testing.T) {
+ g := config.Group{Name: "lan", Policy: "deny", Match: config.Match{CIDRs: []string{"10.0.0.0/8"}}}
+ c := validator(t, g)
+ r := validate(t, c, `{"group":{"name":"lan","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}},"test_ip":"10.1.2.3"}`)
+ if r.Test == nil || r.Test.Group != "lan" || r.Test.Default {
+ t.Errorf("test = %+v", r.Test)
+ }
+ r = validate(t, c, `{"group":{"name":"lan","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}},"test_ip":"8.8.8.8"}`)
+ if r.Test == nil || !r.Test.Default {
+ t.Errorf("test = %+v", r.Test)
+ }
+ r = validate(t, c, `{"group":{"name":"lan","policy":"deny","match":{"cidrs":["10.0.0.0/8"]}},"test_ip":"nope"}`)
+ if r.Test == nil || r.Test.Valid {
+ t.Errorf("test = %+v", r.Test)
+ }
+}
+
+func TestValidateUnreachableResolverIsABadGateway(t *testing.T) {
+ c := validator(t)
+ c.opt.Runtime, _ = NewRuntime("http://127.0.0.1:1", "")
+ rec := httptest.NewRecorder()
+ c.handleClientsValidate(rec, httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"group":{"name":"x"}}`)))
+ if rec.Code != http.StatusBadGateway {
+ t.Errorf("status %d", rec.Code)
+ }
+}
diff --git a/internal/kubeedit/groups.go b/internal/kubeedit/groups.go
index 7cfbb25..c5f9e26 100644
--- a/internal/kubeedit/groups.go
+++ b/internal/kubeedit/groups.go
@@ -129,3 +129,12 @@ func (e *Editor) SyncGroups(ctx context.Context, desired []config.Group, foreign
}
return res, nil
}
+
+// CheckGroupName reports why name cannot be a ClientGroup's object name, so a
+// validation endpoint can say it before a save does.
+func CheckGroupName(name string) error {
+ if name == "" {
+ return fmt.Errorf("a group needs a name")
+ }
+ return checkName("group", name)
+}
diff --git a/internal/kubesource/convert.go b/internal/kubesource/convert.go
index d9329ac..0bb9d79 100644
--- a/internal/kubesource/convert.go
+++ b/internal/kubesource/convert.go
@@ -46,14 +46,44 @@ func convertGroup(u *unstructured.Unstructured) (group, error) {
return groupFromSpec(u.GetName(), s)
}
+// FieldError is a validation failure tied to one field of a ClientGroup, so an
+// editor can show the message next to the input that caused it. Field is the
+// JSON path inside the group ("policy", "match.cidrs", "rate_limit"); "name"
+// and "match" are used for the whole-object cases.
+type FieldError struct {
+ Field string
+ Msg string
+}
+
+func (e FieldError) Error() string { return e.Msg }
+
// CheckGroup validates a ClientGroup spec the way the runtime will, so an
-// editor can refuse a bad one before it is written.
+// editor can refuse a bad one before it is written. It reports the first
+// problem; CheckGroupAll reports every one.
func CheckGroup(name string, s ClientGroupSpec) (config.Group, error) {
- g, err := groupFromSpec(name, s)
- return g.cfg, err
+ g, errs := checkGroup(name, s)
+ if len(errs) > 0 {
+ return g.cfg, errs[0]
+ }
+ return g.cfg, nil
+}
+
+// CheckGroupAll is CheckGroup for an editor that wants to show every problem
+// at once instead of one per save.
+func CheckGroupAll(name string, s ClientGroupSpec) []FieldError {
+ _, errs := checkGroup(name, s)
+ return errs
}
func groupFromSpec(name string, s ClientGroupSpec) (group, error) {
+ g, errs := checkGroup(name, s)
+ if len(errs) > 0 {
+ return group{}, errs[0]
+ }
+ return g, nil
+}
+
+func checkGroup(name string, s ClientGroupSpec) (group, []FieldError) {
g := config.Group{
Name: name,
Comment: s.Comment,
@@ -68,14 +98,18 @@ func groupFromSpec(name string, s ClientGroupSpec) (group, error) {
LogQueries: s.LogQueries,
Upstreams: s.Upstreams,
}
+ var errs []FieldError
+ bad := func(field, format string, args ...any) {
+ errs = append(errs, FieldError{Field: field, Msg: fmt.Sprintf(format, args...)})
+ }
switch g.Policy {
case "allow", "deny", "block_only":
default:
- return group{}, fmt.Errorf("policy must be allow, deny or block_only (got %q)", g.Policy)
+ bad("policy", "policy must be allow, deny or block_only (got %q)", g.Policy)
}
for _, cidr := range g.Match.CIDRs {
if _, _, err := net.ParseCIDR(cidr); err != nil {
- return group{}, fmt.Errorf("bad CIDR %q", cidr)
+ bad("match.cidrs", "bad CIDR %q", cidr)
}
}
// Copy before normalising: the slice belongs to the decoded object.
@@ -85,18 +119,22 @@ func groupFromSpec(name string, s ClientGroupSpec) (group, error) {
g.Match.PTRSuffixes[i] = s + "."
}
}
- if !g.Match.Any && len(g.Match.CIDRs) == 0 && len(g.Match.PTRSuffixes) == 0 && len(g.Match.Tags) == 0 {
- return group{}, fmt.Errorf("match selects no clients: set cidrs, ptr_suffixes, tags or any")
- }
- if g.Match.Any && len(g.Match.CIDRs) == 0 && len(g.Match.PTRSuffixes) == 0 && len(g.Match.Tags) == 0 {
+ selectsSomething := len(g.Match.CIDRs) > 0 || len(g.Match.PTRSuffixes) > 0 || len(g.Match.Tags) > 0
+ switch {
+ case !g.Match.Any && !selectsSomething:
+ bad("match", "match selects no clients: set cidrs, ptr_suffixes, tags or any")
+ case g.Match.Any && !selectsSomething:
// config.Validate refuses this shape for every group. Refusing it here
// rejects one object instead of the whole merged configuration.
- return group{}, fmt.Errorf("match.any alone is not accepted; use a catch-all in the YAML acl.groups")
+ bad("match.any", "match.any alone is not accepted; use a catch-all in the YAML acl.groups")
}
- if g.RateLimit < 0 || g.MaxConcurrent < 0 {
- return group{}, fmt.Errorf("rate_limit and max_concurrent must not be negative")
+ if g.RateLimit < 0 {
+ bad("rate_limit", "rate_limit must not be negative")
}
- return group{cfg: g, priority: s.Priority}, nil
+ if g.MaxConcurrent < 0 {
+ bad("max_concurrent", "max_concurrent must not be negative")
+ }
+ return group{cfg: g, priority: s.Priority}, errs
}
func convertList(u *unstructured.Unstructured) (config.ListSource, error) {
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index f7c931d..c78931a 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -11,7 +11,9 @@ import type {
AddDomainResponse,
BlocklistsResponse,
CacheResponse,
+ ClientGroupInput,
ClientsPayload,
+ GroupValidation,
ClientsResponse,
DomainCheckResponse,
DomainFilters,
@@ -370,6 +372,16 @@ export function putClients(payload: ClientsPayload): Promise {
})
}
+export function validateClientGroup(
+ group: ClientGroupInput,
+ testIp?: string,
+): Promise {
+ return apiFetch('/api/clients/validate', {
+ method: 'POST',
+ body: JSON.stringify({ group, ...(testIp ? { test_ip: testIp } : {}) }),
+ })
+}
+
export function getUpstreams(): Promise {
return apiFetch('/api/upstreams')
}
diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts
index 389c527..61d7769 100644
--- a/web/src/lib/types.ts
+++ b/web/src/lib/types.ts
@@ -345,6 +345,41 @@ export interface ClientsPayload {
default_policy: DefaultPolicy
}
+// --- POST /api/clients/validate ---------------------------------------------
+
+/** One observation about a group, tied to the field it concerns. */
+export interface GroupFinding {
+ /** A JSON path inside the group: "policy", "match.cidrs", "rate_limit". */
+ field: string
+ message: string
+}
+
+/**
+ * What the server says about a group before it is saved. `errors` block the
+ * save; `warnings` are legal settings that probably are not what was meant;
+ * `notes` are facts worth knowing.
+ */
+export interface GroupValidation {
+ ok: boolean
+ errors: GroupFinding[]
+ warnings: GroupFinding[]
+ notes: GroupFinding[]
+ /** The group in plain English, one sentence per entry. */
+ summary: string[]
+ /** No client can ever land in the group: the ones above it claim them all. */
+ unreachable: boolean
+ /** 1-based place in the evaluation order, and the number of groups. */
+ position: number
+ total: number
+ test?: {
+ ip: string
+ valid: boolean
+ group: string
+ default: boolean
+ note: string
+ }
+}
+
// --- GET /api/upstreams ----------------------------------------------------
/**
diff --git a/web/src/pages/Clients.tsx b/web/src/pages/Clients.tsx
index c4bdf97..7dae02f 100644
--- a/web/src/pages/Clients.tsx
+++ b/web/src/pages/Clients.tsx
@@ -7,12 +7,21 @@
* user sees what is wrong next to the input rather than in a toast alone.
*/
-import { useMemo, useState } from 'react'
-import { Copy, Pencil, Plus, Trash2, Users } from 'lucide-react'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import type { ReactNode } from 'react'
+import { AlertTriangle, Check, Copy, Info, Pencil, Plus, Trash2, Users } from 'lucide-react'
-import { ApiError, getClients, putClients } from '../lib/api'
+import { ApiError, getClients, putClients, validateClientGroup } from '../lib/api'
import { useWhoami } from '../lib/session.ts'
-import type { ClientGroup, ClientGroupInput, ClientsPayload, DefaultPolicy, GroupPolicy } from '../lib/types'
+import type {
+ ClientGroup,
+ ClientGroupInput,
+ ClientsPayload,
+ DefaultPolicy,
+ GroupFinding,
+ GroupPolicy,
+ GroupValidation,
+} from '../lib/types'
import { useApi, useAsyncAction } from '../hooks/useApi'
import { fromLines, toLines, formatNumber } from '../lib/format'
import {
@@ -45,14 +54,63 @@ function notify(
}
const POLICIES = [
- { value: 'allow', label: 'Allow — resolve everything' },
- { value: 'block_only', label: 'Block only — no recursion, blocklists apply' },
- { value: 'deny', label: 'Deny — refuse everything' },
+ { value: 'allow', label: 'Allow — resolve normally, blocklists apply' },
+ { value: 'deny', label: 'Deny — refuse every query from these clients' },
+]
+
+/**
+ * block_only does exactly what allow does, so it is not offered. A group that
+ * already has it keeps showing it, rather than silently changing on edit.
+ */
+const LEGACY_POLICY = { value: 'block_only', label: 'Block only (old setting, same as Allow)' }
+
+function policyOptions(current: string) {
+ return current === 'block_only' ? [...POLICIES, LEGACY_POLICY] : POLICIES
+}
+
+/**
+ * Starting points for the common cases. Each fills in the behaviour and leaves
+ * the "who" (name, addresses) to the user, which is the part only they know.
+ */
+const TEMPLATES: { id: string; title: string; text: string; patch: Partial }[] = [
+ {
+ id: 'trusted',
+ title: 'Trusted devices',
+ text: 'Never blocked: ads and trackers resolve normally.',
+ patch: { policy: 'allow', skip_blocklist: true, comment: 'Trusted devices: blocklists are skipped' },
+ },
+ {
+ id: 'restricted',
+ title: 'Only some sites',
+ text: 'Clients can look up only the sites you list. Everything else is blocked.',
+ patch: {
+ policy: 'allow',
+ allow_domains: '*.example.com',
+ comment: 'Allowlist: only the listed sites resolve',
+ },
+ },
+ {
+ id: 'guests',
+ title: 'Guests',
+ text: 'Normal filtering, with a cap on queries per second.',
+ patch: { policy: 'allow', rate_limit: '20', comment: 'Guests: rate limited' },
+ },
+ {
+ id: 'cutoff',
+ title: 'Cut a device off',
+ text: 'Refuses every query from these clients.',
+ patch: { policy: 'deny', comment: 'Cut off: all queries refused' },
+ },
+ {
+ id: 'debug',
+ title: 'Debug a device',
+ text: 'Logs every query and bypasses the cache so each lookup is fresh.',
+ patch: { policy: 'allow', log_queries: true, skip_cache: true, comment: 'Debugging: logged, uncached' },
+ },
]
const DEFAULT_POLICIES = [
{ value: 'allow', label: 'Allow' },
- { value: 'block_only', label: 'Block only' },
{ value: 'deny', label: 'Deny' },
]
@@ -238,6 +296,53 @@ export default function Clients() {
const effectiveDefault = defaultDraft ?? defaultPolicy ?? String(data?.default_policy ?? 'allow')
+ // Live check of the draft against the server: the same validation a save
+ // applies, plus a plain-English reading of what the group will do.
+ const [check, setCheck] = useState(null)
+ const [checkFailed, setCheckFailed] = useState(null)
+ const [testIp, setTestIp] = useState('')
+ const pristine = useRef('')
+ const checkSeq = useRef(0)
+
+ useEffect(() => {
+ if (!editorOpen) return
+ const touched = JSON.stringify(draft) !== pristine.current
+ // Do not shout about an empty name at someone who has just opened the form.
+ if (!touched && draft.name.trim() === '') return
+ const seq = ++checkSeq.current
+ const handle = window.setTimeout(() => {
+ validateClientGroup(toGroup(draft), testIp.trim() || undefined)
+ .then((res) => {
+ if (seq !== checkSeq.current) return
+ setCheck(res)
+ setCheckFailed(null)
+ })
+ .catch((err: unknown) => {
+ if (seq !== checkSeq.current) return
+ setCheckFailed(err instanceof Error ? err.message : 'validation unavailable')
+ })
+ }, 350)
+ return () => window.clearTimeout(handle)
+ }, [draft, editorOpen, testIp])
+
+ const openDraft = (next: GroupDraft, index: number | null): void => {
+ pristine.current = JSON.stringify(next)
+ checkSeq.current++
+ setCheck(null)
+ setCheckFailed(null)
+ setTestIp('')
+ setEditingIndex(index)
+ setDraft(next)
+ setFieldErrors([])
+ setEditorOpen(true)
+ }
+
+ /** Errors the server found on one field of the draft, ready for an input. */
+ const checkError = (...fields: string[]): string | undefined => {
+ const hit = check?.errors.filter((f) => fields.includes(f.field))
+ return hit && hit.length > 0 ? hit.map((f) => f.message).join(' ') : undefined
+ }
+
const save = useAsyncAction(async (payload: ClientsPayload) => {
try {
return await putClients(payload)
@@ -282,30 +387,18 @@ export default function Clients() {
return true
}
- const openNew = (): void => {
- setEditingIndex(null)
- setDraft(EMPTY_DRAFT)
- setFieldErrors([])
- setEditorOpen(true)
- }
+ const openNew = (): void => openDraft(EMPTY_DRAFT, null)
const openEdit = (index: number): void => {
const group = groups[index]
- if (!group) return
- setEditingIndex(index)
- setDraft(fromGroup(group))
- setFieldErrors([])
- setEditorOpen(true)
+ if (group) openDraft(fromGroup(group), index)
}
const duplicate = (index: number): void => {
const group = groups[index]
if (!group) return
const copy = fromGroup(group)
- setEditingIndex(null)
- setDraft({ ...copy, name: `${copy.name}-copy` })
- setFieldErrors([])
- setEditorOpen(true)
+ openDraft({ ...copy, name: `${copy.name}-copy` }, null)
}
const saveGroup = async (): Promise => {
@@ -314,6 +407,10 @@ export default function Clients() {
notify(toast, 'fail', 'a group needs a name')
return
}
+ if (check && !check.ok) {
+ notify(toast, 'fail', 'fix the highlighted problems first')
+ return
+ }
const group = toGroup(draft)
const next = groups.map(toInput)
if (editingIndex !== null && next[editingIndex]) {
@@ -354,7 +451,7 @@ export default function Clients() {
void saveDefault(e.target.value)}
- options={DEFAULT_POLICIES}
+ options={effectiveDefault === 'block_only' ? [...DEFAULT_POLICIES, LEGACY_POLICY] : DEFAULT_POLICIES}
/>
@@ -424,6 +521,7 @@ export default function Clients() {
{matchSummary(group)}
+ {(group.allow_domains?.length ?? 0) > 0 ?
allowlist only : null}
{group.skip_blocklist ?
skip blocklist : null}
{group.skip_cache ?
skip cache : null}
{group.log_queries ?
log : null}
@@ -457,153 +555,215 @@ export default function Clients() {
open={editorOpen}
onClose={() => setEditorOpen(false)}
title={editingIndex === null ? 'New client group' : `Edit ${draft.name}`}
- description="Groups are matched top to bottom; the first match wins."
+ description="A group is a set of devices that share rules. Each query is checked against the groups from the top; the first group that matches the device decides."
size="lg"
footer={
-
+
+ {check && !check.ok ? (
+ Fix the problems marked in red to save.
+ ) : null}
setEditorOpen(false)} disabled={save.pending}>
Cancel
- void saveGroup()} loading={save.pending} disabled={!canWrite}>
+ void saveGroup()}
+ loading={save.pending}
+ disabled={!canWrite || (check !== null && !check.ok)}
+ >
{editingIndex === null ? 'Create group' : 'Save changes'}
}
>
-
-
+
+ {editingIndex === null ? (
+
+
Start from
+
+ {TEMPLATES.map((t) => (
+ setDraft((d) => ({ ...EMPTY_DRAFT, name: d.name, cidrs: d.cidrs, ...t.patch }))}
+ className="rounded-md border border-line bg-surface-2 px-3 py-2 text-left transition-colors hover:border-line-strong hover:bg-surface-3"
+ >
+ {t.title}
+ {t.text}
+
+ ))}
+
+
+ ) : null}
+
+
+
+ setDraft((d) => ({ ...d, name: e.target.value }))}
+ error={checkError('name') ?? fieldError('name')}
+ autoComplete="off"
+ spellCheck={false}
+ />
+ setDraft((d) => ({ ...d, comment: e.target.value }))}
+ />
+
+
+
+
+
+
+
setDraft((d) => ({ ...d, policy: e.target.value }))}
- options={POLICIES}
- />
-
-
setDraft((d) => ({ ...d, comment: e.target.value }))}
- />
-
-
- setDraft((d) => ({ ...d, cidrs: e.target.value }))}
- error={fieldError('cidrs')}
+ options={policyOptions(draft.policy)}
/>
setDraft((d) => ({ ...d, ptr_suffixes: e.target.value }))}
- error={fieldError('ptr_suffixes')}
- />
-
-
setDraft((d) => ({ ...d, tags: e.target.value }))}
- error={fieldError('tags')}
- />
-
-
- setDraft((d) => ({ ...d, allow_domains: e.target.value }))}
- error={fieldError('allow_domains')}
+ error={checkError('allow_domains') ?? fieldError('allow_domains')}
/>
setDraft((d) => ({ ...d, deny_domains: e.target.value }))}
- error={fieldError('deny_domains')}
- />
-
-
- setDraft((d) => ({ ...d, upstreams: e.target.value }))}
- error={fieldError('upstreams')}
- />
-
-
- setDraft((d) => ({ ...d, rate_limit: e.target.value }))}
- hint="0 disables the limit."
- error={fieldError('rate_limit')}
- />
- setDraft((d) => ({ ...d, max_concurrent: e.target.value }))}
- hint="0 means unlimited."
- error={fieldError('max_concurrent')}
- />
-
-
-
- setDraft((d) => ({ ...d, any: next }))}
- label="Match any client"
- description="Last-resort group that matches everything not claimed above."
+ error={checkError('deny_domains') ?? fieldError('deny_domains')}
/>
setDraft((d) => ({ ...d, skip_blocklist: next }))}
- label="Skip blocklists"
- description="Resolve everything, even listed names."
+ label="Ignore the blocklists"
+ description="Names on the blocklists resolve normally for these devices. For trusted devices that break when ads are blocked."
/>
- setDraft((d) => ({ ...d, skip_cache: next }))}
- label="Skip cache"
- description="Always go upstream, never serve a cached answer."
- />
- setDraft((d) => ({ ...d, log_queries: next }))}
- label="Log queries"
- description="Include this group's queries in the query log."
- />
-
+
+
+
+
+ Advanced: limits, upstreams, caching, logging
+
+
+
+
+
{fieldErrors.length > 0 ? (
-
+
The server rejected these fields:
{fieldErrors.map((f) => (
@@ -632,3 +792,86 @@ export default function Clients() {
)
}
+
+/** A titled block of the editor, with an optional line saying what it is for. */
+function FormSection({
+ title,
+ help,
+ children,
+}: {
+ title: string
+ help?: string
+ children: ReactNode
+}) {
+ return (
+
+
+
{title}
+ {help ?
{help}
: null}
+
+ {children}
+
+ )
+}
+
+function FindingRows({ items, tone }: { items: GroupFinding[]; tone: 'danger' | 'warn' | 'info' }) {
+ if (items.length === 0) return null
+ const Icon = tone === 'info' ? Info : AlertTriangle
+ const colour = tone === 'danger' ? 'text-danger' : tone === 'warn' ? 'text-warn' : 'text-fg-subtle'
+ return (
+
+ {items.map((f, i) => (
+
+
+ {f.message}
+
+ ))}
+
+ )
+}
+
+/**
+ * What the server made of the draft: the group in plain English first, then
+ * what blocks a save, what is probably a mistake, and what is just worth
+ * knowing. Fields that have an input show their errors there as well.
+ */
+function CheckPanel({ check, failed }: { check: GroupValidation | null; failed: string | null }) {
+ if (failed) {
+ return (
+
+ Could not check this group against the server ({failed}). Saving still validates it.
+
+ )
+ }
+ if (!check) return null
+ return (
+
+
+ {check.ok ? (
+
+ ) : (
+
+ )}
+
What this group will do
+
+ {check.summary.length > 0 ? (
+
+ {check.summary.map((line) => (
+ {line}
+ ))}
+
+ ) : null}
+
+ {check.unreachable ? (
+
+ As ordered, this group would never be used. Move the group above it, or narrow that one.
+
+ ) : null}
+
+
+
+ )
+}