Commit Graph
32 Commits
Author SHA1 Message Date
mats 64abb69c26 Route the DNS admin ingress to the separate console
build / build-push (push) Successful in 34s
The ingress still sent the UI and admin API to the resolver after the console split. Route both paths through the console so OIDC callbacks and Kubernetes edits reach their owner, while keeping DoH on the resolver.

Include the console resources in Kustomize so GitOps deploys its configuration, permissions, workload and Service alongside the resolver.
2026-10-16 11:45:29 +02:00
mats a3d1614dca rework
build / build-push (push) Successful in 1m8s
2026-10-16 11:13:13 +02:00
matsandClaude Sonnet 5.5 03d771430b Console: return [] for an empty group list, and fix its deployment manifest
GET /api/clients returned "groups": null when there were no groups, because the
merged list was a nil slice. Found against the real cluster.

The console manifest, never applied before, had an image name that does not
exist here, no image pull secret or architecture selector (the resolver's pods
have both), and pointed at a Service (fancy-dns-admin) that is not there. It
now uses the registry image, the pull secret, and the existing fancy-dns-web
Service.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:44:30 +02:00
matsandClaude Sonnet 5.5 242bcd98d9 migrate: read the live address overrides from the zones ConfigMap, not the seed
The old console kept the zone files and the live address-overrides.rpz in the
same fancy-dns-zones ConfigMap; fancy-dns-rpz was only the static seed the
overrides started from. Reading the live cluster showed the two had diverged
(6 entries against 5, the missing one being stackship.k6n.net), so the default
would have silently dropped a redirect. The default is now fancy-dns-zones, and
the test puts a different stale seed in fancy-dns-rpz so reading the wrong one
fails.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:34:31 +02:00
matsandClaude Sonnet 5.5 04ebf68c0f Deploy: read cluster objects, drop the zones and RPZ ConfigMaps
DO NOT SYNC THIS BEFORE MIGRATING. Removing fancy-dns-zones and fancy-dns-rpz
from the kustomization lets a pruning GitOps tool delete them, and they hold the
only copy of the zones and address redirects until the objects exist. Follow
deploy/MIGRATION.md: install the definitions, run `fancy-dns-console migrate`
against the live cluster, then roll this out.

The StatefulSet starts the resolver with -kube and no longer mounts the two
ConfigMaps; its service account loses the ConfigMap role and keeps only the
read-only Role on the fancy-dns objects (crds/rbac.yaml). The kustomization now
installs the definitions and that Role. The shipped config no longer lists the
migrated sources.

Config validation no longer refuses an enabled blocklist with no sources in the
file, because BlockList, DomainRule and AddressOverride objects can supply every
rule and the file cannot know. The existing "no source" warning says so. That
removed the only validation message the settings tests used as a fixture; they
now use an invalid acl.default_policy, and an empty settings document is refused
by the handler instead of by that check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:19:47 +02:00
matsandClaude Sonnet 5.5 467733a7f9 Remove the ConfigMap, RPZ and managed-directory editors the cluster objects replace
Zones, address redirects, inline rules, lists and groups are cluster objects now
(see the previous commits and deploy/MIGRATION.md), so the mechanisms that held
them are gone:

* internal/kubezones and the resolver's service-account access to a ConfigMap,
  the address-overrides read/write path and its poller, the zone-file poller and
  the Local zones editor with its /api/local-zones routes.
* localzone.Managed, the directory of console-written master files, and the
  blocklist.managed_zones key. The resolver now writes no zone data at all; its
  zone hooks only list. The shared half of that file (the record validator, the
  renderer and the round-trip check) stays as zonestate.go, because DNSZone
  rendering is built on it. Its tests are ported to RenderZone, which keeps the
  coverage of every editable record type, wildcards, relative owners, the
  SOA/NS rule and the delegation rule.
* Tests of the removed write paths: the contract test now builds its zone from a
  DNSZone, and a new test pins that every zone write route on the resolver is
  refused and changes nothing.

Standalone installs keep editing master files on disk and SIGHUP, and the
YAML-backed domain, client, upstream and settings editors, which have not moved.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:17:17 +02:00
matsandClaude Sonnet 5.5 88479b9a12 Add AddressOverride and DomainRule objects for what lived in a ConfigMap and the config file
The address redirects page rewrote an RPZ ConfigMap through the resolver's own
service account, and the domains page rewrote allow_entries/block_entries in the
config file. Both become cluster objects like the rest.

AddressOverride is one name answered with fixed addresses (the name and
everything under it), exactly what an RPZ "name A address" line did. DomainRule
is a block or allow list of inline names. The resolver reads both with the
existing watch: overrides are rendered into the managed RPZ source the blocklist
manager already has, and rule names join the config's inline entries in the
merged overlay, so the file's config is never written to.

Names are checked to be DNS names or wildcard patterns before they are used,
because they are joined into line-oriented parser input and a newline or a
comment character in one would otherwise add a rule of its own.

The console edits both: the address-overrides and domains endpoints now write
objects. The console keeps its added rules in one DomainRule per action
(inline-block, inline-allow) instead of an object per name, and removes a name
from whichever DomainRule holds it, wildcard or not. An end-to-end test drives
the pages' endpoints and checks the resolver's answers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 10:08:08 +02:00
matsandClaude Sonnet 5.5 7fd6a7e7a5 Add a Grafana dashboard for the resolver's metrics
With traffic charts gone from the console, deploy/grafana/fancy-dns.json is
where they live: queries by outcome, transport and client group, latency
quantiles, RCODEs, cache hits/misses/stale, the upstream round trips saved by
coalescing and early refresh, upstream health and latency, blocklist and zone
sizes, and (in a Loki row) the blocked/failed-query access log. A node variable
spreads or narrows every panel, and the per-node panel shows whether load is
even across pods.

A test reads the dashboard and fails if a panel queries a metric the resolver
does not export, so a rename cannot silently empty a panel.

The ingress no longer needs the SSE buffering/timeout annotations, and the
statefulset comments no longer describe a query log on the volume.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:57:38 +02:00
matsandClaude Sonnet 5.5 01202f9785 Console: replace the dashboard, logs and cache pages with a status page and links
With the resolver no longer storing traffic history or a query log, the pages
built on them have nothing to show. The Dashboard becomes a Status page: health
and the reasons for a degraded state, uptime, what is compiled (blocklist rules,
local zone records), the cache with a flush button, per-pod upstream health and
the listeners. Logs and Cache are removed along with the SSE client, the chart
code and the recharts dependency.

api.links (grafana, logs, metrics) names where the charts and the query log
live. GET /api/links serves them and the Status page shows buttons for the ones
that are set, or says how to set them. They are rendered as hrefs, so config
validation refuses anything but http(s) and the page checks again.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:56:26 +02:00
matsandClaude Sonnet 5.5 c6c5ca4ff7 Make the resolver pure DNS: JSON access log on stdout instead of a log store
The resolver kept its own query-log store (a bbolt ring, a live ring with SSE
fanout, rolling per-domain counters and dashboard time-series) for the console.
In a multi-node deployment each pod held a different slice of that, so every
view disagreed with itself, and the cost sat on the query path. Charts belong
in Grafana and log search in the log collector, so the store is gone.

internal/accesslog writes one JSON line per notable query to stdout. The default
is what was blocked and what failed (blocked, redirected, SERVFAIL, dropped,
refused); log_cached and sample_rate add ordinary answers for a debugging
session. Lines go through a bounded buffer to a single writer goroutine and are
dropped, and counted in fancydns_access_log_dropped_total, when the stream
cannot keep up, so a stalled log pipe can never stall a query. The config key is
access_log (the old qlog block is ignored, like any unknown key).

The metrics gain what the dashboard used to compute: fancydns_group_queries_total
by ACL group and outcome and fancydns_responses_total by RCODE. Removed with the
store: /api/dashboard, /api/logs, /api/logs/stream, /api/logs/clear,
/api/cache/top, the series rings, and the cache's per-name hit table, which took
a mutex and built a string on every cache hit.

Tests that read the old log now read the access-log lines; the contract test
drops the removed endpoints. The web console still calls them and is changed in
the next commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 09:53:04 +02:00
matsandClaude Sonnet 5.5 1a536e7d0e Split the console from the resolver: fancy-dns-console writes the cluster
fancy-dns-console is a second binary holding the web UI, authentication and
every edit. It writes ClientGroup, BlockList and DNSZone objects through the new
kubeedit package and forwards all other /api routes to a running resolver
with its own key, dropping the browser's credentials, so it can run in the
cluster or on a workstation with a kubeconfig and nothing about the resolver's
placement matters. The resolver never imports kubeedit and the console is not on
the DNS path.

kubeedit validates every spec with the runtime's own converters
(kubesource.CheckGroup/CheckList/CheckZone) before writing, so the console
cannot store an object the resolver would reject. Zone edits keep the
file-based writer's behaviour: duplicate and below-a-delegation records are
refused, a delegation in a new zone also gives the zone its servers, and a
zone with no apex NS is never stored. Each object records the editing user in
an annotation; updates retry on conflict.

The resolver reports groups from ClientGroup objects as source "kubernetes" and
counter-only rows (the "(default)" bucket, or a group removed since) as
"history", and lists DNSZone zones as the editable ones. The console uses that
to leave the YAML's groups alone and to drop history rows, which the UI would
otherwise send back on save. Its GET /api/clients overlays the cluster's
current objects on the resolver's list, because the resolver only sees an edit
after its watch fires and a save made just after another would delete the
first.

An end-to-end test runs both halves against one fake apiserver: a group saved
through the console refuses queries, a zone built record by record answers
authoritatively, and a request with the resolver's own key is rejected by the
console.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 08:43:41 +02:00
matsandClaude Sonnet 5.5 4d38945d94 Add a read-only Kubernetes source for zones, client groups and blocklists
The runtime can now take ClientGroup, BlockList and DNSZone objects
(fancydns.k6n.net/v1alpha1) from a cluster with -kube, from a pod's service
account or a kubeconfig alike. internal/kubesource watches them with dynamic
informers, converts and validates each object on its own, and publishes an
immutable snapshot. A bad object is skipped and keeps its last good version,
so one broken edit cannot remove a running policy; an invalid merged config is
dropped whole. The last snapshot is saved so a restart without apiserver access
still serves.

config.Store gains an overlay: Base() is what the YAML says, Get() is base plus
cluster objects. Everything that writes the config back (mutateConfig,
SetAdminAddr, the settings editor) now starts from Base(), otherwise cluster
objects would be copied into the file and shadow their own objects. YAML wins
every name collision. putACL and toggleBlocklist refuse to edit cluster-owned
objects. BlockList has no path field and refuses file:// URLs so a namespaced
object cannot pick files the runtime reads or writes. DNSZone is rendered
through the zone writer's own prove-by-reparse step, with the SOA serial taken
from metadata.generation so every replica renders identical bytes.

qlog: Flush reset the pending slice to [:0], so entries logged while it was
still storing the batch were appended over the same backing array. The race
detector found it through the new Kubernetes integration test. pending is now
set to nil, with a regression test that fails without the change.

CRD manifests, RBAC and examples are in deploy/k8s/crds; tests pin the schemas
to the Go spec structs so the two cannot drift.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 08:35:36 +02:00
mats 32b214fac0 one replica for now
build / build-push (push) Successful in 35s
2026-09-29 12:22:01 +02:00
mats d48c1863bf more config
build / build-push (push) Successful in 24s
2026-09-28 21:34:59 +02:00
mats 87b6946ab7 Support RPZ address overrides for local DNS
build / build-push (push) Successful in 30s
RPZ A records were parsed as address redirects, but the query pipeline treated the address as a CNAME target and returned the wrong result. Answer IP-literal redirects directly with the matching A or AAAA record, and return NODATA for other query types.

Add the requested EdgeRouter domain overrides as a local RPZ source so each bare owner covers its apex and all subdomains. Mount that source into the pods and document the address-record form. When the Zones page has no existing zone, start the Add Record form in New zone mode and tell the operator to enter its apex.
2026-09-28 15:37:46 +02:00
mats 47ef5c1133 Schedule fancy-dns on amd64 nodes
build / build-push (push) Successful in 21s
The published image is built for amd64 only, but the StatefulSet had no architecture constraint and could be scheduled on arm64 nodes. Restrict pods to Kubernetes amd64 nodes so every replica can run the image.
2026-09-28 15:15:03 +02:00
mats 64cf99121c Enable console-managed Kubernetes zones
build / build-push (push) Successful in 24s
The Kubernetes config left blocklist.managed_zones empty, so the Zones page was read-only even though the pod has a writable persistent data volume. Configure a managed-zone directory on that volume so the console can create and update zone records; existing local_zones files remain operator-owned and read-only.
2026-09-28 15:13:49 +02:00
mats f488727508 update deployment
build / build-push (push) Successful in 29s
2026-09-28 15:05:21 +02:00
developer 504512f247 Add a Zones console: create and manage local zones, records and delegations
The console could not see local zones at all. `blocklist.local_zones` compiled
a store that nothing read back: there was no API for a zone, its records, its
delegations or the file a 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 the half of the server that
answers authoritatively from disk. There is now a Zones page and the endpoints
behind it: `GET /api/zones`, `POST`/`PUT`/`DELETE /api/zones/records`,
`POST`/`DELETE /api/zones/delegations` and `DELETE /api/zones?zone=`.

**The console owns one directory and nothing else.** `blocklist.managed_zones`
holds one `<apex>.zone` per zone, with that zone's SOA, apex NS set and the
records added there, and 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 and no restart. Files in `blocklist.local_zones` are
listed and shown read-only and are never rewritten; the key defaults to empty,
so a deployment that never sets it gets a zone browser and no zone writes.
Which file a record came from is now part of the store (`zone.files`, keyed by
the index the store already uses), so the snapshot can say "console" or "your
file" per record; the query path does not read it.

**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 exactly as it would be in a zone file; the file is rendered, and the
rendering is re-parsed and compared with the records it meant to write before
the file is renamed into place. A writer bug that dropped a record would
otherwise present as a zone that silently stopped answering. A zone that cannot
answer is refused rather than created: no SOA means no NXDOMAIN or NODATA
authority, and no apex NS means nothing is delegated and no server is named. The
SOA's primary follows the apex NS set — a zone whose SOA names a server outside
its own NS set hands out a referral no resolver can follow — and the serial
moves on every write.

**A delegation takes the names under it, so a record below a cut is refused.**
The store answers those names as delegated without reading the zone file, which
would make the record data that looks alive and never answers. `DELETE
/api/zones?zone=` exists for the same reason from the other side: a zone the
console created is a real zone, so it says NXDOMAIN for what it does not hold,
and deleting a record is not what gives a name back to upstream. It is
refused for a zone that also has records outside the managed directory, because
removing that file would remove none of the zone.

**Two bugs the work turned up.** `engine.applyConfig` published local-record
metrics only when `blocklist.local_zones` was non-empty, so a zone the console
created was served but never counted; the gate is now the store's own record
count. And the record-value renderer fell back to `RR.String()` for every type
it did not know, which is the whole line rather than the RDATA — it wrote a
name and a TTL into the value column for DHCID, LOC, SSHFP and SVCB, and would
have fed that back as a record value on the next edit.

Every write recompiles the store and flushes the cache. Which names changed is
not knowable from the API, and a cached upstream answer for a name the zone now
answers is simply wrong, so the flush is blunt on purpose.

The wire shapes are type aliases in `internal/api` rather than copies, so there
is one set of json tags, and `TestZoneEndpointKeySets` pins the key set at every
level with a record and a delegation actually present — an empty list only
proves the envelope. `TestZoneWritesNeedAManagedDirectory` pins the read-only
default, and `TestZoneConsoleRoundTrip` walks the whole thing: a record added
through the API is answered authoritatively on the next query, a cut stops the
parent answering below it, and deleting the zone hands the name back.

Verified against a running instance built from this tree: records and
wildcards answer with `aa`, relative names are qualified, a record below a cut
is refused with a message that says why, and the file on disk is a zone file
an operator can read. The console page is typechecked and built; the browser was
not available in this round, so it has not been looked at.
2026-09-28 07:45:16 +00:00
mats 32f940b763 Add UI editing for local DNS zones
build / build-push (push) Successful in 29s
Local zones were only editable as mounted files, so operators could not manage DNS entries from the console. Add a Local zones editor backed by a shared Kubernetes ConfigMap, validate master files before saving, scope the pod service account to that ConfigMap, and reload changed zones on every replica while flushing stale cache entries.
2026-09-28 08:50:50 +02:00
mats 2348d2e4b4 add
build / build-push (push) Successful in 30s
2026-09-27 22:32:03 +02:00
mats d53552f855 deploy: use cluster registry pull secret
build / build-push (push) Successful in 21s
2026-09-27 21:56:50 +02:00
mats 3a199bb310 fix
build / build-push (push) Failing after 30s
2026-09-27 21:15:20 +02:00
developer 9881d2a095 Make the API response contract a test; fix what it found
The console's TypeScript 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: the Go suite was
green and the console was wrong. internal/app/contract_test.go now asserts the
exact JSON key set of every admin endpoint, driven through the real router
with the real hook implementations, plus a test that drives the two write
endpoints with the exact payload web/src builds.

The drift it found, all four fixed here:

blocklist.Source and SourceMeta had no json tags at all, so GET /api/blocklists
sent Go field names ("URL"/"Format") where the console expects snake_case: the
Sources table rendered blank rows and its per-row toggle posted
`name: undefined`. Tagged to match ParseStats.

acl.GroupSummary was flat where the console reads a nested `match` object — the
same shape config.Group has — so every group card read "matches nothing". The
four match fields are nested now, under a new acl.Match.

PUT /api/clients answered 400 `json: unknown field "ptr_suffixes"` to the
console's payload, because config.Group and config.Match carried yaml tags only
and the decoder runs with DisallowUnknownFields. Both structs have json tags
now; the same applies to config.UpstreamServer and UpstreamBootstrap.

PUT /api/upstreams answered 400 `json: unknown field "health"`, because the
console round-trips the whole server object it read, runtime health fields
included. They are accepted and ignored, because the server owns them, and they
are `yaml:"-"` so settings.yaml does not gain runtime state.

Also fixed, each with a test:

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 — the same spelling config.yaml uses — and a
bare JSON number is still accepted and means seconds, not nanoseconds.
TestDurationJSONForm used to pin the old behaviour and now pins the new one,
saying in its comment that 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.

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 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.

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() and
peerDeadline(), which fall back to the default.

Two cluster tests raced on the wire, not on the product, and one was
environment-dependent rather than wrong. 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; the divergence test waited for
a digest on the wrong node's view of the fleet; and 127.0.0.1:1 is only
unbindable outside a container that sets
net.ipv4.ip_unprivileged_port_start=0, where it binds and the partial-bind test
fails with both transports up. That test now occupies a port with a live
listener in the test process, which is a deterministic EADDRINUSE on every
machine and every uid.

internal/server/counters_test.go covers the counter-key layer that no other
test touched: the "<transport>.<field>" keyspace, that the aggregate folds only
the known fields, and that the snapshot is safe while counters move.

Two pinned behaviours changed on purpose, each with a test that says so:

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 still ask for
it. This changes which queries get blocked for an existing policy zone, which is
why it wanted a decision. TestExampleRPZZoneCoversTheApexItNames pins both
halves 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 outcome="local" series was
never published — the dashboard's local column was permanently empty.
Response.Local is now set on the local-zone path and outcomeFor consults it
first. That moves published counters, so the README's metrics section says so
too.
2026-09-27 18:42:59 +00:00
developer e8c5b414d9 half broken
build / build-push (push) Failing after 23s
2026-09-27 17:17:53 +00:00
developer cc7466823b Wire the memory envelope into the k8s ConfigMap; bring STATUS.md up to date
build / build-push (push) Failing after 19s
The container limit is 1Gi, so declare the envelope next to it: a 1GiB soft
heap limit, a 256MiB reserve for what the Go heap does not account for, and an
explicit 384MiB cache byte budget. cache.size is an entry count, so the byte
budget is what actually bounds the cache now that ParseSize applies its units.

STATUS.md records the round: the fixes, the new suites (engine, config.Size,
metrics, and the api suite that landed separately), three findings left
unfixed -- the login rate limiter never applies to the login endpoint, auth
configuration is frozen at New, and api.constantTimeEqual is dead -- and the
continuation plan with items 1, 2, 5 and 6 struck.
2026-09-27 16:03:25 +00:00
mats 1e67a5d0f9 update deployment
build / build-push (push) Failing after 30s
2026-09-27 17:29:08 +02:00
mats e1026b87db fix cd
build / build-push (push) Failing after 30s
2026-09-27 16:44:32 +02:00
developer b0e6b3bb92 Add the memory envelope, pprof, and a per-packet fix on the UDP path
ci / Console typecheck and build (push) Successful in 14s
ci / Go build, vet and test (push) Failing after 45s
ci / Binary with the console embedded (push) Skipped
ci / Container image (push) Skipped
A cgroup OOM kill is the worst outcome available to a resolver: an outage
rather than a cache miss, with no chance to serve stale on the way out. So:

- memory.limit / memory.reserve / memory.cache_max_bytes, a new config section.
  limit becomes a GOMEMLIMIT through debug.SetMemoryLimit, reduced by the
  reserve. The reserve exists because the Go heap does not account for
  goroutine stacks, runtime metadata, or the bbolt mmap backing the cache
  snapshot and the query log — handing the runtime the full cgroup limit is how
  you get killed by a mechanism that believed it had headroom.
- config.Size, so a budget reads as "512MiB" rather than a byte count converted
  by hand. Strict about junk: "512MiBx" is an error.
- The cache byte budget is now actually fed to the LRU. It already enforced
  maxBytes per shard on insert, but had no setter and nothing ever called one,
  so the footprint of cache.size was unbounded: one cached ANY or TXT answer
  can be tens of kilobytes, so 500k entries is anywhere from 150MB to several GB
  depending on what the traffic asked for.

- api.pprof mounts net/http/pprof on the admin listener. The pprof handlers sit
  on the app-level mux, not inside the API's own handler, so they would bypass
  the auth middleware entirely; the option is therefore refused unless every
  admin address is loopback. Profiling means port-forwarding, so this is safe
  by construction rather than by policy, and cmdline cannot leak an argument
  that happens to be a key.

- serveUDP was calling net.ResolveUDPAddr per packet. The peer already is a
  *net.UDPAddr; that call builds a string, re-parses the port and allocates on
  the busiest path in the process.

Also fixes a flaky test: TestParallelPolicyAnswersFromTheFastestUpstream
asserted the slow upstreams were hit, but the fan-out cancels on the first
answer, so whether they were dispatched before cancellation is a real race. It
now asserts the answer came from the fast upstream and that nothing waited.
2026-09-27 13:41:48 +00:00
developer 26134ba437 Fix the bbolt round trip, the blocklist gaps and the deploy set
ci / Console typecheck and build (push) Successful in 13s
ci / Go build, vet and test (push) Failing after 22s
ci / Binary with the console embedded (push) Skipped
ci / Container image (push) Skipped
The cache snapshot never loaded, so every restart was a cold cache. Snapshot
packed the whole key while Load decoded the record header out of the key's
remainder instead of the value, so every entry was skipped. The two sides are
now an explicit encodeRecord/decodeRecord pair with a version byte and a CRC-32C
trailer, so a future format change is detected instead of silently misparsed.
Load also drops entries that are already past ExpiresAt and rewrites the TTL to
the time actually left, so a served TTL never jumps back up across a restart.

Blocklist, all six gaps the failing tests were pointing at: a leading UTF-8 BOM
is stripped, "!allow example.com" works, SourceMeta exists for a source that
only ever contributed a rule, Pattern() reports the bare name again (the new
WireName() produces the wildcard form for the API), an RPZ record with no
explicit TTL inherits the zone default instead of whatever the reader left in
the RR, a hosts line with no address in the first field says so, and the
adblock parser handles CRLF and adblock-flavoured lines pasted into a plain
list. TestHolderSwapIsAtomic was asserting the wrong invariant — the swap was
always atomic, a name blocked by the old set is legitimately unblocked by the
new one.

Tests added for acl and resolver, both of which turned up real bugs:

- a PTR suffix matched across label boundaries, so a group scoped to
  example.com. also swallowed ample.com. and notexample.com. — a client could
  land in a group it does not belong to;
- the "*" catch-all was dropped from the rendered domain patterns, so the
  engine's per-query recompile lost the "everything" case;
- Upstream.Success never closed an open breaker, so a recovered upstream stayed
  excluded for the full cooldown;
- Exchange fanned out in parallel even under policy: failover.

Deploy set: go.mod requires Go 1.27.1 but CI pinned 1.23 and the Dockerfile
pinned golang:1.23-alpine, so neither could have built. The Dockerfile also
copied the source tree after the console bundle, which clobbered it.

The k8s set asked for hostNetwork with two replicas, but the server never sets
SO_REUSEPORT, so the second pod could not bind :53 and would crash-loop. It now
runs without hostNetwork behind a LoadBalancer Service, with a separate
ClusterIP Service for the Ingress (a headless Service cannot back one), a
ServiceAccount, an API-key Secret, per-pod PVCs, and a kustomization. The HPA
is gone: it cannot drive a StatefulSet. To let the API key come from that
Secret, config.Load now expands ${VAR} / ${VAR:-default} / ${NAME-default} and
fails to start on an unset variable with no default, so a missing secret is a
crash loop rather than an unauthenticated console. Expansion skips YAML
comments, so documenting ${VAR} in the ConfigMap does not break the file.

Verified: gofmt, go build, go vet clean; go test -race ./internal/... green
(acl, app, blocklist, cache, config, resolver, zonefile).
2026-09-27 13:30:43 +00:00
developer ee72257c64 more code 2026-09-27 11:34:26 +00:00
developer 85453852d5 initial commit 2026-09-27 11:20:34 +00:00