Commit Graph
20 Commits
Author SHA1 Message Date
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 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
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 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 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