more stuff
build / build-push (push) Successful in 1m11s

This commit is contained in:
mats committed 2026-10-16 14:49:50 +02:00
1 parent 289a8cc2d8
commit 66f36cfac6
19 files changed
+1905 -817

No files matched your search

+27 -28
View File
@@ -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 <base>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
+114 -633
View File
@@ -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
`<apex>.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.<zone>` 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.
+292
View File
@@ -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 `<data dir>/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 <<EOF
apiVersion: v1
kind: Config
clusters:
- name: cluster
cluster: {server: ${SERVER}, certificate-authority-data: ${CA}}
users:
- name: fancy-dns-backup
user: {token: ${TOKEN}}
contexts:
- name: backup
context: {cluster: cluster, user: fancy-dns-backup, namespace: dns}
current-context: backup
EOF
chmod 600 fancy-dns.kubeconfig
```
On the controller node itself you can point `server:` at the local API server
(for example `https://127.0.0.1:6443`) so the backup does not depend on the
pod network to reach it. Use the address the API server's certificate is valid
for, or the connection fails the TLS check.
Check the credentials before you trust them. This is the check that matters,
because `-check` below validates only the YAML and not the cluster connection:
```sh
kubectl --kubeconfig /etc/fancy-dns/kubeconfig -n dns get clientgroups,dnszones,blocklists
# and the negative: this must be refused
kubectl --kubeconfig /etc/fancy-dns/kubeconfig -n dns delete clientgroups --all --dry-run=server
```
## 2. Install and configure the resolver
Build the binary (`make build` writes `dist/fancy-dns` and
`dist/fancy-dns-console`, with the console embedded) or copy it out of the image.
Use the same version as the cluster: an older binary rejects objects that use
fields it does not know, and reports `kubernetes: objects rejected` in `components.degraded`.
```sh
sudo useradd --system --home /var/lib/fancy-dns --shell /usr/sbin/nologin fancy-dns
sudo install -m 0755 dist/fancy-dns /usr/local/bin/fancy-dns
sudo install -d -o fancy-dns -g fancy-dns /var/lib/fancy-dns
sudo install -d /etc/fancy-dns
sudo install -m 0600 -o fancy-dns -g fancy-dns fancy-dns.kubeconfig /etc/fancy-dns/kubeconfig
```
Start from `deploy/compose/config.yaml` (a backup-resolver config) or
`deploy/config.example.yaml`. The parts that matter here:
```yaml
cache:
persist:
enabled: true
path: /var/lib/fancy-dns/cache.db # its directory is the data dir: the
# cluster snapshot is saved beside it
api:
enabled: true
listen: ["127.0.0.1:8080"] # loopback only
ui: false
auth:
mode: apikey
api_keys: ["${FANCYDNS_API_KEY}"]
```
Keep in the YAML only what is genuinely local: listeners, upstreams, cache.
**Do not copy the cluster's groups, lists or zones into it.** YAML wins every
collision, so a copy would shadow the live object and you would be editing in
the console what the backup ignores. The upstreams and `forward_zones` are
config, not objects, so they must match the in-cluster ones by hand; compare the
two files after any change.
The service reads its secrets from an environment file:
```sh
sudo install -m 0600 /dev/null /etc/fancy-dns/fancy-dns.env
echo "FANCYDNS_API_KEY=$(openssl rand -hex 24)" | sudo tee -a /etc/fancy-dns/fancy-dns.env
```
An unset `${VAR}` is a startup failure on purpose: a missing key is a crash
loop, not an unauthenticated API.
### systemd
`/etc/systemd/system/fancy-dns.service`:
```ini
[Unit]
Description=fancy-dns backup resolver
Wants=network-online.target
After=network-online.target
[Service]
User=fancy-dns
Group=fancy-dns
EnvironmentFile=/etc/fancy-dns/fancy-dns.env
ExecStart=/usr/local/bin/fancy-dns \
-config /etc/fancy-dns/config.yaml \
-kubeconfig /etc/fancy-dns/kubeconfig \
-kube-namespace dns
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=2
TimeoutStopSec=20
LimitNOFILE=65536
# Port 53 without root.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/var/lib/fancy-dns
[Install]
WantedBy=multi-user.target
```
Always pass `-kube-namespace`. Outside a pod there is no namespace to default
to, and watching all namespaces would need a cluster-wide Role.
`-kubeconfig` turns the Kubernetes source on by itself. `reload` is a SIGHUP:
the YAML is re-read and every component is swapped without dropping a query.
Changes to the cluster objects need no reload at all.
### Port 53
Check what owns it first: `sudo ss -lntup | grep ':53 '`. `systemd-resolved`'s
stub listener binds only `127.0.0.53`, but a wildcard bind to `0.0.0.0:53`
still collides with it. Either bind the node's address in `dns.udp`/`dns.tcp`
or set `DNSStubListener=no` in `/etc/systemd/resolved.conf`. Open UDP and TCP 53
(and 853/443 if you enabled DoT/DoH) in the node's firewall.
Do not make this resolver the node's only DNS server (`/etc/resolv.conf`): after
a reboot the node needs name resolution before the resolver is up.
## 3. Start it and check
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now fancy-dns
journalctl -u fancy-dns -n 30 --no-pager # look for: watching Kubernetes for fancy-dns objects
KEY=$(sudo sed -n 's/^FANCYDNS_API_KEY=//p' /etc/fancy-dns/fancy-dns.env)
curl -s -H "X-API-Key: $KEY" http://127.0.0.1:8080/api/status | jq '.components'
dig @127.0.0.1 example.com +short
dig @127.0.0.1 <a name from one of your DNSZones or AddressOverrides> +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=<a long random key: what you type into the UI>
FANCYDNS_RUNTIME_KEY=<the resolver's FANCYDNS_API_KEY: what the console sends it>
```
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. |
+5
View File
@@ -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:
+4 -5
View File
@@ -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:
+6
View File
@@ -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)]
+6
View File
@@ -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) {
+414
View File
@@ -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, ", ")
}
+172
View File
@@ -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)
}
}
+3 -2
View File
@@ -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"`
+1
View File
@@ -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))
+2 -1
View File
@@ -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
+193
View File
@@ -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
}
+181
View File
@@ -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)
}
}
+9
View File
@@ -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)
}
+51 -13
View File
@@ -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) {
+12
View File
@@ -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<SaveResponse> {
})
}
export function validateClientGroup(
group: ClientGroupInput,
testIp?: string,
): Promise<GroupValidation> {
return apiFetch<GroupValidation>('/api/clients/validate', {
method: 'POST',
body: JSON.stringify({ group, ...(testIp ? { test_ip: testIp } : {}) }),
})
}
export function getUpstreams(): Promise<UpstreamsResponse> {
return apiFetch<UpstreamsResponse>('/api/upstreams')
}
+35
View File
@@ -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 ----------------------------------------------------
/**
+378 -135
View File
@@ -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<GroupDraft> }[] = [
{
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<GroupValidation | null>(null)
const [checkFailed, setCheckFailed] = useState<string | null>(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<void> => {
@@ -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() {
<div className="space-y-4">
<PageHeader
title="Clients"
description="Group clients by network, reverse-DNS name or tag and give each group its own policy."
description="Give some devices different rules: no ad blocking for a trusted laptop, only approved sites for a child's tablet, a hard stop for a misbehaving camera. The first group that matches a device wins."
actions={
<Button
icon={Plus}
@@ -379,7 +476,7 @@ export default function Clients() {
value={effectiveDefault}
disabled={loading || save.pending || !canWrite || !defaultEditable}
onChange={(e) => void saveDefault(e.target.value)}
options={DEFAULT_POLICIES}
options={effectiveDefault === 'block_only' ? [...DEFAULT_POLICIES, LEGACY_POLICY] : DEFAULT_POLICIES}
/>
</div>
</Card>
@@ -424,6 +521,7 @@ export default function Clients() {
{matchSummary(group)}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{(group.allow_domains?.length ?? 0) > 0 ? <Badge tone="warn">allowlist only</Badge> : null}
{group.skip_blocklist ? <Badge tone="info">skip blocklist</Badge> : null}
{group.skip_cache ? <Badge tone="info">skip cache</Badge> : null}
{group.log_queries ? <Badge tone="muted">log</Badge> : 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={
<div className="flex justify-end gap-2">
<div className="flex items-center justify-end gap-2">
{check && !check.ok ? (
<span className="mr-auto text-xs text-danger">Fix the problems marked in red to save.</span>
) : null}
<Button variant="ghost" onClick={() => setEditorOpen(false)} disabled={save.pending}>
Cancel
</Button>
<Button onClick={() => void saveGroup()} loading={save.pending} disabled={!canWrite}>
<Button
onClick={() => void saveGroup()}
loading={save.pending}
disabled={!canWrite || (check !== null && !check.ok)}
>
{editingIndex === null ? 'Create group' : 'Save changes'}
</Button>
</div>
}
>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="space-y-6">
{editingIndex === null ? (
<div>
<p className="mb-2 text-sm font-medium">Start from</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{TEMPLATES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => 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"
>
<span className="block text-sm font-medium">{t.title}</span>
<span className="block text-xs text-fg-subtle">{t.text}</span>
</button>
))}
</div>
</div>
) : null}
<FormSection title="Name">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Name"
hint="Lowercase letters, digits, '-' and '.', e.g. kids-tablets."
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
error={checkError('name') ?? fieldError('name')}
autoComplete="off"
spellCheck={false}
/>
<Input
label="Note to self"
hint="Optional. Shown on the group's card."
value={draft.comment}
onChange={(e) => setDraft((d) => ({ ...d, comment: e.target.value }))}
/>
</div>
</FormSection>
<FormSection
title="Which devices?"
help="A device is in the group if it matches any one of the boxes below. Addresses are the most reliable; names and tags need working reverse DNS (PTR records)."
>
<Textarea
label="IP addresses or ranges"
hint="One per line. One device: 192.168.1.20/32. A whole network: 192.168.1.0/24."
monospace
rows={3}
value={draft.cidrs}
onChange={(e) => setDraft((d) => ({ ...d, cidrs: e.target.value }))}
error={checkError('match.cidrs') ?? fieldError('cidrs')}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Textarea
label="Reverse-DNS name ends with"
hint="One per line, e.g. lan.example.net or .lan. Matches devices whose address resolves back to a name under it."
monospace
rows={2}
value={draft.ptr_suffixes}
onChange={(e) => setDraft((d) => ({ ...d, ptr_suffixes: e.target.value }))}
error={checkError('match.ptr_suffixes') ?? fieldError('ptr_suffixes')}
/>
<Textarea
label="Name tags"
hint="One word per line. A tag is one part of a device's reverse name: laptop for laptop.lan.example.net."
monospace
rows={2}
value={draft.tags}
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
error={checkError('match.tags') ?? fieldError('tags')}
/>
</div>
{checkError('match', 'match.any') ? (
<p className="text-sm text-danger">{checkError('match', 'match.any')}</p>
) : null}
<Input
label="Name"
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.target.value }))}
error={fieldError('name')}
label="Try an address"
hint="Type a device's IP to see which group it would land in once this group is saved."
value={testIp}
onChange={(e) => setTestIp(e.target.value)}
placeholder="192.168.1.20"
autoComplete="off"
spellCheck={false}
/>
{check?.test ? (
<p
className={
check.test.valid ? 'text-sm text-fg-muted' : 'text-sm text-danger'
}
>
{check.test.valid
? check.test.default
? check.test.note
: `${check.test.ip} lands in "${check.test.group}". ${check.test.note}`
: check.test.note}
</p>
) : null}
</FormSection>
<FormSection
title="What happens to their queries?"
help="Names are looked up normally and the blocklists are applied, unless you change that below."
>
<Select
label="Policy"
value={draft.policy}
onChange={(e) => setDraft((d) => ({ ...d, policy: e.target.value }))}
options={POLICIES}
/>
</div>
<Input
label="Comment"
value={draft.comment}
onChange={(e) => setDraft((d) => ({ ...d, comment: e.target.value }))}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Textarea
label="Client CIDRs"
hint="One per line, e.g. 192.168.1.0/24"
monospace
rows={4}
value={draft.cidrs}
onChange={(e) => setDraft((d) => ({ ...d, cidrs: e.target.value }))}
error={fieldError('cidrs')}
options={policyOptions(draft.policy)}
/>
<Textarea
label="Reverse-DNS suffixes"
hint="One per line, e.g. .lan"
monospace
rows={4}
value={draft.ptr_suffixes}
onChange={(e) => setDraft((d) => ({ ...d, ptr_suffixes: e.target.value }))}
error={fieldError('ptr_suffixes')}
/>
</div>
<Textarea
label="Tags"
hint="One per line; matches ECS/DNS tag metadata when the transport provides it."
monospace
rows={2}
value={draft.tags}
onChange={(e) => setDraft((d) => ({ ...d, tags: e.target.value }))}
error={fieldError('tags')}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Textarea
label="Allow domains"
hint="Glob patterns that skip the blocklists for this group."
label="Only allow these sites (allowlist)"
hint="Leave empty for no restriction. If you list anything, these devices can look up ONLY these names and everything else is blocked. *.example.com covers example.com and everything under it."
monospace
rows={3}
value={draft.allow_domains}
onChange={(e) => setDraft((d) => ({ ...d, allow_domains: e.target.value }))}
error={fieldError('allow_domains')}
error={checkError('allow_domains') ?? fieldError('allow_domains')}
/>
<Textarea
label="Deny domains"
hint="Glob patterns always refused for this group."
label="Always block these sites"
hint="Blocked for this group even if no blocklist has them. Wins over the allowlist."
monospace
rows={3}
value={draft.deny_domains}
onChange={(e) => setDraft((d) => ({ ...d, deny_domains: e.target.value }))}
error={fieldError('deny_domains')}
/>
</div>
<Textarea
label="Upstreams"
hint="Optional per-group resolver addresses, e.g. 10.0.0.53:5353"
monospace
rows={2}
value={draft.upstreams}
onChange={(e) => setDraft((d) => ({ ...d, upstreams: e.target.value }))}
error={fieldError('upstreams')}
/>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Rate limit (qps)"
type="number"
min={0}
value={draft.rate_limit}
onChange={(e) => setDraft((d) => ({ ...d, rate_limit: e.target.value }))}
hint="0 disables the limit."
error={fieldError('rate_limit')}
/>
<Input
label="Max concurrent queries"
type="number"
min={0}
value={draft.max_concurrent}
onChange={(e) => setDraft((d) => ({ ...d, max_concurrent: e.target.value }))}
hint="0 means unlimited."
error={fieldError('max_concurrent')}
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Switch
checked={draft.any}
onChange={(next) => 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')}
/>
<Switch
checked={draft.skip_blocklist}
onChange={(next) => 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."
/>
<Switch
checked={draft.skip_cache}
onChange={(next) => setDraft((d) => ({ ...d, skip_cache: next }))}
label="Skip cache"
description="Always go upstream, never serve a cached answer."
/>
<Switch
checked={draft.log_queries}
onChange={(next) => setDraft((d) => ({ ...d, log_queries: next }))}
label="Log queries"
description="Include this group's queries in the query log."
/>
</div>
</FormSection>
<details className="rounded-md border border-line">
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium">
Advanced: limits, upstreams, caching, logging
</summary>
<div className="space-y-4 border-t border-line p-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Input
label="Queries per second, per device"
type="number"
min={0}
value={draft.rate_limit}
onChange={(e) => setDraft((d) => ({ ...d, rate_limit: e.target.value }))}
hint="Above this, queries are refused. 0 means no limit, which also exempts the group from the default limit."
error={checkError('rate_limit') ?? fieldError('rate_limit')}
/>
<Input
label="Queries in flight, per device"
type="number"
min={0}
value={draft.max_concurrent}
onChange={(e) => setDraft((d) => ({ ...d, max_concurrent: e.target.value }))}
hint="Extra queries get SERVFAIL. 0 means no cap for this group."
error={checkError('max_concurrent') ?? fieldError('max_concurrent')}
/>
</div>
<Textarea
label="Use only these upstream resolvers"
hint="One per line, written exactly as on the Upstreams page (e.g. 1.1.1.1:53). Empty means the normal upstreams."
monospace
rows={2}
value={draft.upstreams}
onChange={(e) => setDraft((d) => ({ ...d, upstreams: e.target.value }))}
error={checkError('upstreams') ?? fieldError('upstreams')}
/>
<Switch
checked={draft.skip_cache}
onChange={(next) => setDraft((d) => ({ ...d, skip_cache: next }))}
label="Bypass the cache"
description="Every query goes upstream and nothing is cached. Slower; for debugging."
/>
<Switch
checked={draft.log_queries}
onChange={(next) => setDraft((d) => ({ ...d, log_queries: next }))}
label="Always log their queries"
description="Written to the access log even when logging is otherwise limited to blocked queries."
/>
</div>
</details>
<CheckPanel check={check} failed={checkFailed} />
{fieldErrors.length > 0 ? (
<div className="rounded-md bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-300">
<div className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger">
<p className="font-medium">The server rejected these fields:</p>
<ul className="mt-1 list-inside list-disc font-mono text-xs">
{fieldErrors.map((f) => (
@@ -632,3 +792,86 @@ export default function Clients() {
</div>
)
}
/** 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 (
<section className="space-y-3">
<div>
<h4 className="text-sm font-semibold">{title}</h4>
{help ? <p className="mt-0.5 text-xs text-fg-subtle">{help}</p> : null}
</div>
{children}
</section>
)
}
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 (
<ul className="space-y-1.5">
{items.map((f, i) => (
<li key={`${f.field}-${i}`} className={`flex gap-2 text-sm ${colour}`}>
<Icon className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<span>{f.message}</span>
</li>
))}
</ul>
)
}
/**
* 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 (
<p className="rounded-md border border-line px-3 py-2 text-sm text-fg-subtle">
Could not check this group against the server ({failed}). Saving still validates it.
</p>
)
}
if (!check) return null
return (
<section
aria-live="polite"
className="space-y-3 rounded-md border border-line bg-surface-2 px-3 py-3"
>
<div className="flex items-center gap-2">
{check.ok ? (
<Check className="size-4 text-ok" aria-hidden="true" />
) : (
<AlertTriangle className="size-4 text-danger" aria-hidden="true" />
)}
<h4 className="text-sm font-semibold">What this group will do</h4>
</div>
{check.summary.length > 0 ? (
<ul className="list-inside list-disc space-y-1 text-sm text-fg-muted">
{check.summary.map((line) => (
<li key={line}>{line}</li>
))}
</ul>
) : null}
<FindingRows items={check.errors} tone="danger" />
{check.unreachable ? (
<p className="text-sm font-medium text-warn">
As ordered, this group would never be used. Move the group above it, or narrow that one.
</p>
) : null}
<FindingRows items={check.warnings} tone="warn" />
<FindingRows items={check.notes} tone="info" />
</section>
)
}