1 parent
289a8cc2d8
commit
66f36cfac6
19 files changed
+1905
-817
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ", ")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user