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

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

No files matched your search

+27 -28
View File
@@ -275,15 +275,15 @@ file.
| `acl.groups` | list | empty | evaluated top to bottom, first match wins |
| `acl.groups[].name` / `comment` | string | — | `name` required and unique |
| `acl.groups[].match.cidrs` | list of CIDR | empty | matched against the client address (v4 or v6) |
| `acl.groups[].match.ptr_suffixes` | list | empty | matched against reverse-DNS names of the client, on a label boundary; a missing trailing dot is added |
| `acl.groups[].match.ptr_suffixes` | list | empty | matched against reverse-DNS names of the client, on a label boundary; a missing trailing dot is added. `lan.example.` covers the name and everything under it; `.lan.example.` (leading dot) covers only what is under it |
| `acl.groups[].match.tags` | list | empty | labels derived from the reverse name (e.g. `laptop` for `laptop.lan.internal.`) |
| `acl.groups[].match.any` | bool | `false` | catch-all; must be the last group |
| `acl.groups[].policy` | `allow` \| `deny` \| `block_only` | `allow` | `deny` answers REFUSED and wins over the local zone |
| `acl.groups[].allow_domains` / `deny_domains` | list | empty | per-group name policy with the blocklist wildcards; `deny_domains` is checked first |
| `acl.groups[].skip_blocklist` | bool | `false` | the group opts out of blocklist filtering |
| `acl.groups[].policy` | `allow` \| `deny` \| `block_only` | `allow` | `deny` answers REFUSED and wins over the local zone. **`block_only` behaves exactly like `allow` today**: both resolve normally and both apply the blocklists |
| `acl.groups[].allow_domains` / `deny_domains` | list | empty | per-group name policy with the blocklist wildcards (`*.x` and `**.x` both cover `x` itself here). **A non-empty `allow_domains` is allowlist-only mode**: every name not in it is blocked for the group; it does not exempt names from the blocklists (that is `skip_blocklist`). `deny_domains` is checked first and wins |
| `acl.groups[].skip_blocklist` | bool | `false` | the group opts out of blocklist filtering (local zones and address overrides are unaffected) |
| `acl.groups[].skip_cache` | bool | `false` | the group only gets fresh lookups |
| `acl.groups[].rate_limit` | int | inherit | queries/second for this group |
| `acl.groups[].max_concurrent` | int | `0` (off) | in-flight cap for this client |
| `acl.groups[].max_concurrent` | int | `0` (off) | in-flight cap for this client; the excess get SERVFAIL |
| `acl.groups[].log_queries` | bool | `false` | log every query from this group regardless of `access_log` selection |
| `acl.groups[].upstreams` | list of addrs | empty | restrict this group to named upstreams; a list that matches nothing falls back to the whole pool |
@@ -521,6 +521,7 @@ is on.
| POST | `/api/domains` | yes | add an inline rule |
| POST | `/api/dns/test` | — | run one query through the live pipeline (`{"name","type"}`) and report the rcode, action, answers, upstream and latency — the console's "is my DNS answering" test, and a failed lookup is a 200 with a result, not a failed request |
| DELETE | `/api/domains/{name…}?action=&list=` | yes | remove a rule |
| POST | `/api/clients/validate` | — | console only. Check a client group without saving it: `{"group":{…},"test_ip":"…"}` answers `errors` (what blocks a save, with the field), `warnings` (legal but probably unintended: a group buried under a wider one, a rate limit of 0 under a default limit, an allowlist), `notes`, a plain-English `summary`, the group's `position` in the evaluation order, and which group `test_ip` would land in |
| GET | `/api/zones` | — | every local zone with its records, delegations, source files, whether each is an editable cluster object, the record types the console writes, and why the store is stale (`error`) |
| POST | `/api/zones/records` | yes | add a record (`{"zone","name","type","ttl","value"}`; a name without a trailing dot is relative to the zone) |
| PUT | `/api/zones/records` | yes | replace the whole RRset of one name and type |
@@ -580,7 +581,7 @@ docker run --rm -p 53:53/udp -p 53:53/tcp -p 8080:8080 \
The `Dockerfile` builds the console in a Node stage, then a static
`CGO_ENABLED=0` binary with it embedded, and ships it on
`gcr.io/distroless/static-debian12:nonroot`. It declares `VOLUME
/var/lib/fancy-dns` (cache snapshot, query log, downloaded lists) and copies
/var/lib/fancy-dns` (cache snapshot, downloaded lists, the saved cluster snapshot) and copies
`deploy/config.example.yaml` plus `deploy/zones/` to `/etc/fancy-dns/`; there
is no `config.yaml` in the image, so its `CMD` (`-config
/etc/fancy-dns/config.yaml`) requires you to mount one.
@@ -626,7 +627,7 @@ Things to know before you apply it:
* **Per-pod PVC sizing.** The StatefulSet uses a 4 Gi `ReadWriteOnce`
template. `cache.size` is an *entry count*, not bytes: a packed answer is
roughly 300–600 bytes on the wire and nearer 1 kB resident, so 500k entries
lands around 500 MB, and `qlog.max_entries` is the other consumer. The PVC
lands around 500 MB, and the downloaded blocklists are the other consumer. The PVC
cannot be shared between replicas, and being a StatefulSet also means an HPA
cannot drive the replica count.
* Probes all hit `/api/healthz` on the admin port, which is unauthenticated on
@@ -771,72 +772,70 @@ Found while writing this file; each is a to-do, not something fixed here.
6. **`internal/config/config.go:229` — `cache.prewarm` is dead.**
`App.prewarm` exists (`internal/app/app.go:220`) but nothing calls it, while
`deploy/config.example.yaml:97` ships `prewarm: []`.
7. **`internal/config/config.go:358` — `qlog.log_client_names` is dead.**
8. **`internal/config/config.go:373` — `cluster.peer_sync` is dead**, and
7. **`internal/config/config.go:373` — `cluster.peer_sync` is dead**, and
**`cluster.advertise`** (`:371`, "the address other instances dial for the
admin API") is only echoed in heartbeats: no code dials a peer.
9. **`internal/config/config.go:411` — `api.allow_blocklist_write` is dead.**
8. **`internal/config/config.go:411` — `api.allow_blocklist_write` is dead.**
Only `allow_config_write` is enforced (`internal/api/handlers.go:
handleSettingsPut`).
10. **`internal/config/config.go:418` — `metrics.path` is dead.** The route is
9. **`internal/config/config.go:418` — `metrics.path` is dead.** The route is
built from `api.base_path` (`internal/api/api.go:routes`:
`GET <base>metrics`), so `metrics.path: /metrics` in
`deploy/config.example.yaml:196` and in `deploy/k8s/00-config.yaml` has no
effect; the endpoint is `/api/metrics`.
11. **`internal/config/config.go` — `dns.doh.transport.alt_svc` (`:157`) and
10. **`internal/config/config.go` — `dns.doh.transport.alt_svc` (`:157`) and
`.mode` (`:156`), `dns.dot.upstream` (`:129`), `dns.edns0.cookie_support`
(`:166`), `dns.edns0.client_subnet` (`:162`), `dns.edns0.ipv4_bits` /
`ipv6_bits` (`:163-164`) are all dead.** `serveDoH` always speaks h2 with an
h2c fallback (no HTTP/3, no Alt-Svc), there is no encrypted-upstream DoT
switch (`upstream.servers[].proto: tls` is the way to get that), and ECS is
forwarded verbatim whenever `dns.edns0.preserve` is on.
12. **`internal/config/config.go:266` — `blocklist.parallelism` is dead**;
11. **`internal/config/config.go:266` — `blocklist.parallelism` is dead**;
`RefreshAll` compiles single-threaded.
13. **`internal/config/config.go` (`ListSource.Timeout`) and
12. **`internal/config/config.go` (`ListSource.Timeout`) and
`server.timeouts.list_fetch`** — the fetch timeout is never applied:
`blocklist.NewManager` hardcodes a 120 s `http.Client` and
`internal/blocklist/manager.go:fetch` never consults `l.Timeout`.
14. **`internal/config/config.go` (`UpstreamBootstrap.Refresh`)** — bootstrap
13. **`internal/config/config.go` (`UpstreamBootstrap.Refresh`)** — bootstrap
names are resolved once per pool rebuild; `refresh` never triggers a
re-resolution.
15. **`internal/config/config.go` (`Valkey.TLS`)** — `cluster.valkey.tls` is
14. **`internal/config/config.go` (`Valkey.TLS`)** — `cluster.valkey.tls` is
never applied; `internal/cluster/cluster.go:dial` builds a plain
`redis.Client` with no `TLSConfig`.
17. **`internal/zonefile/zonefile.go:1-8` and `:238-243` — the package comment
16. **`internal/zonefile/zonefile.go:1-8` and `:238-243` — the package comment
advertises `$INCLUDE`, the parser rejects it** ("`$INCLUDE … is not
supported; use blocklist.local_zones or a list source instead`"), and
`Options.MaxIncludeDepth` is never set by any caller. Either implement it or
correct the comment and drop the field.
18. **`Makefile:113` — `make run` claims it runs
17. **`Makefile:113` — `make run` claims it runs
`deploy/config.example.yaml` "on unprivileged ports", but that file binds
`0.0.0.0:53` and `[::]:53`** (`deploy/config.example.yaml:23-24`).
Unprivileged use needs the same `sed` rewrite the CI smoke test does
(`.github/workflows/ci.yml`, "Smoke test the real binary"), or a port you
can bind.
20. **`deploy/k8s/20-service.yaml` — the load-balancer comment says to leave
19. **`deploy/k8s/20-service.yaml` — the load-balancer comment says to leave
the LB health check on the HTTP admin port, but the `fancy-dns-dns`
LoadBalancer Service publishes only 53/UDP, 53/TCP and 853/TCP.** The admin
port is on the separate `fancy-dns-web` ClusterIP Service, which the LB
cannot health-check. Pick a provider with UDP health checks or use DNS-level
failover.
21. **`deploy/k8s/00-config.yaml` enables `dot` and `doh` against
20. **`deploy/k8s/00-config.yaml` enables `dot` and `doh` against
`/etc/fancy-dns/tls/tls.crt` while the Secret it mounts ships empty
`tls.crt` / `tls.key`.** Applying the kustomization unchanged leaves both
listeners unable to build a `tls.Config`; a comment says so, but an example
that cannot come up as-is is still a footgun.
22. **`api.rate_limit.per_minute` defaults to `60` in `config.Default()` but
21. **`api.rate_limit.per_minute` defaults to `60` in `config.Default()` but
`deploy/config.example.yaml:189` says `600`**, and
`deploy/config.example.yaml` sets `qlog.log_cached: false` where the default
is `true`. Harmless, but the example is not a dump of the defaults and
readers will assume it is.
23. **`api.auth.mode: apikey` with an emptied `api_keys` list and
`deploy/config.example.yaml` is not a dump of the defaults, and readers will
assume it is.
22. **`api.auth.mode: apikey` with an emptied `api_keys` list and
`trust_loopback: true` is indistinguishable from `none`.** `Validate` only
rejects the combination with `trust_loopback: false`; the unsafe case is
reported solely as a warning from `POST /api/settings/validate`.
24. **`config.Load` does not use `yaml.Decoder.KnownFields`**, so unknown keys
23. **`config.Load` does not use `yaml.Decoder.KnownFields`**, so unknown keys
are ignored silently. A settled decision (pinning, not changing, the
behaviour); `TestLoadIgnoresUnknownKeys` in `internal/config` holds it.
25. **RESOLVED — an RPZ record did not cover the name it names.**
24. **RESOLVED — an RPZ record did not cover the name it names.**
`rpzRule` gave a bare owner name the subdomain form (`*.name`), so
`analytics.example.org.` in a policy zone blocked
`cdn.analytics.example.org.` and left `analytics.example.org.` resolving
@@ -849,7 +848,7 @@ Found while writing this file; each is a to-do, not something fixed here.
`TestExampleRPZZoneCoversTheApexItNames`, including the `rpz-passthru.`
half — the same gap meant a passthru record did not allowlist its own
domain.
26. **`internal/api/api.go:288-317` — `api.cors_origins: ["*"]` echoes the
25. **`internal/api/api.go:288-317` — `api.cors_origins: ["*"]` echoes the
request's `Origin` *and* sets `Access-Control-Allow-Credentials: true`.**
That is deliberate (a literal `*` is ignored by browsers when credentials
are involved, so echoing is the only way a wildcard can work with a session