Commit Graph
70 Commits
Author SHA1 Message Date
mats e1026b87db fix cd
build / build-push (push) Failing after 30s
2026-09-27 16:44:32 +02:00
mats f70f28839f fix ci 2026-09-27 16:30:16 +02:00
developer bbd8692bab Add AGENTS.md; bring STATUS.md up to a green tree
ci / Console typecheck and build (push) Successful in 13s
ci / Go build, vet and test (push) Successful in 20s
ci / Binary with the console embedded (push) Successful in 18s
ci / Container image (push) Failing after 7s
AGENTS.md is the part that should not churn: the settled decisions with their
reasons, the invariants that hold the design together, the working rules, and
the security-sensitive areas that have been wrong before. A new agent should
read it and STATUS.md and know what not to re-litigate.

Deliberately excluded: anything about how to search, read or patch. Those are
being automated per round, and writing advice about them into the repo would
rot. What is here instead is project-specific and cannot be automated away —
for example that a PTR suffix must match on a label boundary, that a truncated
answer must never be cached, and that the UDP read deadline has to be reset every
iteration or the loop spins.

The working rules are the part worth arguing for. Never leave the tree red,
because nobody can detect the budget running out: three of five subagents left
broken files, and each cost a round to discover. Report honestly, and when a test
fails, decide first whether the test, the implementation or the specification is
wrong — all three happen here, and the decision belongs in the commit message.

STATUS.md now records a fully green tree: nine packages with suites, all passing
under -race. The plan starts with internal/engine, which is the largest
uncovered surface and the one every packet goes through.
2026-09-27 14:17:14 +00:00
developer 16c8ad27b9 Make the Rebind concurrency test deterministic
ci / Console typecheck and build (push) Successful in 13s
ci / Go build, vet and test (push) Successful in 19s
ci / Binary with the console embedded (push) Successful in 17s
ci / Container image (push) Failing after 8s
It asserted that a reader had to observe both of the swapped-in upstream
addresses, which is a demand about *which* list a reader happens to see. A
reader that finished its sampling before the rebind loop got there satisfied
every real property and still failed, so this failed roughly 2 runs in 10 and
would have turned CI red on nothing.

What the test should pin is the copy-on-write invariant itself: a reader must
only ever see a whole, published list. Each reader now compares every observed
snapshot against the three lists this test publishes, and fails immediately on
a half-applied one. That is checked on every iteration rather than inferred
afterwards, and it makes no demand about which list was seen, so it cannot
flake. 15 consecutive -race runs, no failure.
2026-09-27 14:12:48 +00:00
developer a5e54c09df Qualify relative RDATA names in the zone-file parser
ci / Console typecheck and build (push) Successful in 13s
ci / Go build, vet and test (push) Successful in 20s
ci / Binary with the console embedded (push) Successful in 17s
ci / Container image (push) Failing after 8s
A domain name in the RDATA section written without a trailing dot is
relative to the zone origin (RFC 1035 §5.1), so "www CNAME host" in
example.com. means host.example.com. The qualifier ran on the record
after dns.NewRR had already resolved every RDATA name against the root,
which turns "host" into "host." and makes the two indistinguishable --
relative() saw the trailing dot and left it alone. Every relative CNAME,
NS, MX, PTR, SRV, NAPTR, SOA and SVCB target therefore pointed at the
DNS root, which showed up as a local zone answering a CNAME with
"host." and the alias target holding no local answer. Qualification now
happens in assemble on the token stream, before the record is built,
using a table of the RDATA positions that hold a name per type; names
that already end in a dot stay absolute, so the RPZ targets "." and
"rpz-drop." and the null MX targets are unaffected.

Fix three wrong expectations in the localzone tests while here. The loop
fixture was built with dns.NewRR on "alias.example.com", which cannot
parse because the RR library wants a TTL and class column, so the
expected record now spells them out. The origin-derivation fixture
used "a" as an owner name, and a leading token that names an RR type
reads as an omitted owner, which has no previous owner to inherit on
the first line of a file; the owner is now a word. The merge test
counted the SOA, but it is zone metadata rather than a queryable
record, so two files contribute three records. The holder race test also
looked up a name from the base zone while the holder was serving a store
compiled from other.test., which no answer could satisfy; it now
accepts either store's data.
2026-09-27 14:10:25 +00:00
developer ad9989b28c Update STATUS.md: green/red map, the narrowed localzone bug, revised plan
ci / Console typecheck and build (push) Successful in 13s
ci / Go build, vet and test (push) Successful in 19s
ci / Binary with the console embedded (push) Successful in 18s
ci / Container image (push) Failing after 9s
Records the current state rather than the previous plan. The important change
is in the localzone section: the relative-CNAME defect is narrowed to how
Store re-serialises records, not the parser — qualifyRdata and relative are
both correct and assemble only touches the owner, so the next person should
look at localzone rather than "fixing" the parser.

Also adds make check-go to the dev loop, the memory envelope, the env-expansion
fix (the admin API key was the literal string "${FANCYDNS_API_KEY}" behind a
green build), the two open decisions, and a reworked plan starting with the one
red package.

Adds to the hygiene notes: subagent briefs must require that whatever is on disk
when the budget runs out compiles and passes, because an agent cannot know it is
out of budget. Three of five left a red tree.
2026-09-27 14:01:47 +00:00
developer 4c00bb4144 Fix $TTL in the zone parser; add make check-go
ci / Console typecheck and build (push) Successful in 12s
ci / Binary with the console embedded (push) Canceled after 0s
ci / Container image (push) Canceled after 0s
ci / Go build, vet and test (push) Canceled after 2m24s
The zone parser ignored an explicit $TTL and stamped every record with the SOA
minimum instead, so a 60s zone served 3600s and DefaultTTL was wrong too.
RFC 1035 §5.2: an explicit $TTL wins. qualifyRdata was also added to apply
RFC 1035 §5.1 relative-name expansion to RDATA, which is correct but has one
outstanding defect noted below.

make check-go is the tight inner loop: gofmt check, go vet, compile. It uses
go-build rather than build, because build depends on web and shells out to npm
to rebuild a console bundle that rarely changes. check now depends on it, so
the fast path and the CI path cannot drift apart.

Outstanding, not fixed here: a relative CNAME target is still emitted as
"host." instead of "host.example.com.". qualifyRdata is called with the right
origin, and relative() is correct in isolation, so the target is almost
certainly being dot-terminated in Zone.assemble before dns.NewRR ever sees it,
which makes relative() treat it as already absolute. Narrowed to that, not
confirmed — see the localzone test failures.
2026-09-27 13:59:31 +00:00
developer f02bb0aecf Fix env expansion: it never worked inside quotes, and empty was fatal
ci / Console typecheck and build (push) Successful in 14s
ci / Go build, vet and test (push) Successful in 1m14s
ci / Binary with the console embedded (push) Successful in 18s
ci / Container image (push) Failing after 11s
The `${VAR}` support added with the Kubernetes API-key Secret was silently
useless in the only form anyone would write it. The reference check sat in the
same switch as the quote-state handling, below the `inDouble`/`inSingle`
branches, so both of those branches consumed the bytes and a reference inside a
quoted scalar was copied through verbatim. `api_keys: ["${KEY}"]` — the exact
form deploy/k8s/00-config.yaml uses — came out of Load unchanged, so the key
arrived as the literal string "${FANCYDNS_API_KEY}" and the admin API was
protected by nothing. Everything looked fine because no test asserted on the
quoted form at the Load boundary until now.

Two further corrections while in there:

- A variable that is set but empty now expands to empty instead of being an
  error. `${X}`, `${X-y}` and `${X:-y}` differ precisely on that point, and
  getting it wrong turns an intentionally blank secret into a startup failure.
- A reference is skipped when it follows a backslash, so "\${FOO}" is literal.

Also fixes two test fixtures that had an `inline:` key, which is not a field —
the struct spells it block_entries — and were failing Validate for a reason
that had nothing to do with what they were testing. That is the same
silent-ignore-unknown-keys behaviour flagged in STATUS.md, caught here by a
test that thought it was testing environment expansion.
2026-09-27 13:45:59 +00: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 1bdb0b081c docs: write README.md from the source
ci / Console typecheck and build (push) Successful in 14s
ci / Go build, vet and test (push) Failing after 15s
ci / Binary with the console embedded (push) Skipped
ci / Container image (push) Skipped
2026-09-27 13:39:12 +00:00
developer 2b84474228 Rewrite STATUS.md to match the tree after the bbolt/blocklist/acl/resolver fixes
ci / Console typecheck and build (push) Successful in 14s
ci / Go build, vet and test (push) Failing after 15s
ci / Binary with the console embedded (push) Skipped
ci / Container image (push) Skipped
Records the four bugs the first end-to-end run surfaced, what is green, what
is still without a suite, and what is in flight, so the next session does not
start from the previous plan.
2026-09-27 13:32:17 +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
mats 3bcc10a029 Merge pull request 'Fix/web typecheck' (#1) from fix/web-typecheck into main
ci / Console typecheck and build (push) Failing after 16s
ci / Go build, vet and test (push) Failing after 2m59s
ci / Binary with the console embedded (push) Skipped
ci / Container image (push) Skipped
Reviewed-on: #1
2026-09-27 15:01:51 +02:00
developer 728374dfda STATUS.md: correct the deliverables section
ci / Console typecheck and build (pull_request) Failing after 23s
ci / Go build, vet and test (pull_request) Failing after 1m1s
ci / Binary with the console embedded (pull_request) Skipped
ci / Container image (pull_request) Skipped
Makefile, Dockerfile, .gitignore, CI workflow and two k8s manifests do
exist; only README.md is empty and the k8s set is partial.
2026-09-27 12:59:27 +00:00
developer aa12b03eb9 fix data races in cache, resolver, qlog; add cache/zonefile/blocklist tests; update STATUS
- cache.Entry.Hits and LRU.perShard become atomics; SetCapacity now evicts
  down to the new budget; packKey fully qualifies names before packing
- resolver.Pool rewritten copy-on-write (atomic.Pointer to an immutable
  state); Upstream is immutable with shared *upstreamStats, so health
  survives a Rebind and the query path stops racing the config hook
- Pool.Exchange no longer marks a dead upstream healthy: Success was
  called before the error check, so the breaker never tripped
- qlog live ring publishes through atomic pointers
- blocklist.Holder.Get nil-safe; Builder lists each source once
- web: fix toast(string) misuse that rendered empty mutation toasts
- add unit tests for cache (with benchmarks) and zonefile
- STATUS.md: green -race integration suite, remaining failures and the
  broken bbolt snapshot round trip documented
2026-09-27 12:59:02 +00:00
developer 10e8a9b91a more code 2026-09-27 12:34:58 +00:00
developer ee72257c64 more code 2026-09-27 11:34:26 +00:00
developer f24fd63e9a web: give the toast helper a real type and keep the dist bundle out of git
Blocklists, Clients, Domains and Upstreams each carried a ToastLike shim
that duck-typed a react-hot-toast style API. The console uses its own
Toast context, so notify() was calling toast('some message') with a string
where ToastOptions is expected: every mutation toast rendered an empty
title. They now take the toast function from useToast() and send a real
{ title, tone } payload, mapping 'fail' onto the danger tone.
2026-09-27 11:32:39 +00:00
developer 85453852d5 initial commit 2026-09-27 11:20:34 +00:00
mats 5b935301e5 first commit 2026-09-27 11:44:33 +02:00