An imperative scale left kubectl owning the replica count while GitOps still declared one, causing apply conflicts. Declare two replicas so DNS remains available across a node failure and GitOps can reconcile the intended count.
The source copy overwrote the generated index.html with a tracked stale version after the web stage had supplied fresh hashed assets. Browsers requested a missing script and received the SPA HTML fallback. Copy the web stage output last so the embedded entrypoint and assets come from the same build.
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.
The console now works as the cluster's editor, without the monitoring pages.
Web: Blocklists gets Add/Edit/Remove for BlockList objects (and shows ones the
resolver has not compiled yet); Clients marks cluster groups and locks config-file
groups and the default policy; Upstreams becomes a read-only health view when the
config cannot be written; Zones and Domains lose the managed-directory and list
copy; the Local zones page and its API calls are removed; nav is Status,
Blocklists, Domains, Lookup, Address redirects, Zones, Clients, Upstreams, Settings.
Console: a change to a group the resolver's config file defines is refused with the
reason instead of being skipped silently, and /api/clients says what the console
can and cannot edit.
Server: isFileWritable called a read-only existing file (a mounted ConfigMap)
writable, and api.allow_config_write was not reflected in the writable flag nor
checked by the upstream/ACL edit path. Both fixed; tests pin them.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Committed unchanged, by someone other than its author, so that the cluster UI
work that follows has a base to build on and can be reviewed separately: the
rewritten Overrides page (forms, validation, row-level state instead of
one-line handlers), the form and table clean-ups on Domains, Zones, Upstreams,
Settings and LocalZones, the AppShell fix that moves the shell inside the
identity provider, new nav labels, and the removal of the unused Kbd and
SectionTitle components and the Vite starter assets.
Login.tsx is deliberately not part of this: it belongs with the server-side OIDC
login work, which is still uncommitted, and stays in the working tree.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
Found by rolling the resolver out to the real cluster.
BlockList objects have no path, deliberately, so the first rollout downloaded
the list at every start. The cluster DNS that the download needs is this very
resolver, which was not up yet, so the first compile failed after 14 seconds,
held the DNS listeners closed for that long, and left the pod with no ad
blocking at all. Three changes:
* Lists from cluster objects get a copy under the runtime's own data directory
(data/lists/<name>), chosen by the runtime from the object's name, never by the
object.
* The copy is used as the answer only while it is younger than the list's
refresh interval, and otherwise the list is downloaded again, with the old copy
as the fallback if that fails. Before, any copy on disk was treated as current
for ever, so a list with a path was frozen at its first download. A source now
ages from its file, an operator's "refresh now" forces a download, and a
failed download keeps the stale copy.
* Startup loads only what is on disk (RefreshOffline), opens the listeners, and
fetches stale or missing lists in the background. The resolver answers at once
and the network is never on its critical path.
An address override answered as a redirect and so counted as a policy action:
with the access log logging redirects, every lookup of an internal name wrote a
line. Overrides from the managed source are local answers now; a redirect a list
imposes is still logged.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
Both found by running the resolver locally against real upstreams.
response_ttl is a duration (60s) and a DNS TTL is seconds, but eight places
converted with a bare uint32(), which keeps the low 32 bits of the nanosecond
count. A blocked name's negative answer carried an SOA TTL and minimum of
997195776 (about 31 years), so any client or downstream cache that honoured it
would remember the block, and keep remembering it after the name was
unblocked. blocklist.TTLSeconds converts properly (whole seconds, clamped to
2^31-1) and is used by the rule parser and the block, sinkhole and redirect
answers. The tests read the TTL off the wire; with the old conversion they fail
with 4165425152.
An ordinary upstream NXDOMAIN was classified as an error. A name that does not
exist is the normal result of a typo, a search-domain expansion or a leaked mDNS
query, so on a real network the error metric counted mostly those and, with the
access log's errors-only default, every one wrote a line. Only the upstream
failing or refusing (SERVFAIL, REFUSED, FORMERR, NOTIMP) is an error now.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
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>
The settings of the ConfigMap-and-YAML deployment have to become objects before
the mechanisms that held them can go. The command reads what the old console
wrote (the zone--<apex>.zone keys of the zones ConfigMap and the
address-overrides RPZ), and optionally the resolver's config.yaml, and converts:
zone master files -> DNSZone
address-overrides RPZ -> AddressOverride, one per name
URL blocklists -> BlockList
allow_entries/block_entries -> DomainRule inline-allow / inline-block
acl.groups -> ClientGroup
By default it only prints manifests, which can be reviewed or committed; -apply
creates or updates the objects. It never deletes or edits the ConfigMaps or the
config. Each conversion is validated with the code the resolver uses to read the
objects, and anything it cannot represent faithfully is listed and left alone:
a zone with a record type the API does not write, a list that is a file on the
pod, a name that is not a legal object name, an RPZ line that is not an A or
AAAA override. Tests apply a conversion of the live data to a fake cluster and
read it back through the resolver's source, and run the command against fake
ConfigMaps, offline from files, and with the ConfigMaps missing.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The DNS listeners bind TCP and UDP on one port, and the harness probed TCP only.
Under a full parallel run the port could be one another test's ephemeral UDP
client socket held, the listener failed to bind, and the queries saw connection
refused. That made TestConsoleEditsReachTheResolver fail once in a full suite and
pass 12 of 12 on its own.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
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>
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>
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>
Concurrent cache misses for one name each went upstream. After a restart, a
cache flush or a cluster-object change that is a burst of identical queries
exactly when the cache is empty. Identical exchanges (same name, type, class,
DO bit, pinned upstream set and forwarded client subnet) now share one: the
first query runs it on its own goroutine and context, the rest wait and get
private copies, and the leader alone writes the cache. If the leader's client
leaves, waiters take over instead of failing with its cancellation. Shared
answers are counted in fancydns_upstream_coalesced_total.
validate() accepted OpcodeUpdate, so a dynamic update was forwarded upstream as
an ordinary lookup, and every other opcode got FORMERR. A recursive resolver
implements QUERY only: anything else is now NOTIMP (RFC 1035 4.1.1), checked
before the question count since STATUS carries no question.
Prefetch only fired when a stale entry was served, so the first client after a
hot name expired got a TTL-1 stale answer. A fresh entry is now refreshed in
the background when it has been served at least twice and under a tenth of its
lifetime remains; the hit counter restarts with each stored entry, so a name
nobody comes back for stops being refreshed. Counted in
fancydns_cache_prefetch_total.
miekg/dns matches only the message ID of an upstream reply, so a forged reply
with the right ID for a different question was accepted and cached under the
name that was asked. exchangeOne now requires the reply to carry the question
that was sent (an empty section only for FORMERR, SERVFAIL, NOTIMP and
REFUSED). A mismatch fails the attempt without counting against the upstream,
so a spoofer cannot trip the circuit breaker.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
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>
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>
The initial upstream exchange ignored its supplied timeout and used hardcoded client limits. Bound each attempt with a context, copy the shared client for its timeout, and share the attempt budget with UDP-to-TCP fallback. Close private connections on cancellation to interrupt reads, and exclude caller cancellation from upstream failures so parallel losers remain healthy.
The normal engine pipeline never applied server.timeouts.query, allowing failover to outlive the configured query budget. Apply a hot-reloaded overall context deadline at Handle while retaining earlier caller deadlines. Add silent UDP/TCP/TLS, cancellation, fallback, failover, and reload regressions.
The server inflight test assumed receiving a TCP reply meant its sent counter had already been incremented. Wait for both completion counters before asserting them, preserving the existing assertions while removing the scheduling race.
The query log classified every non-drop blocklist match as a block, even when an RPZ A or CNAME rule returned a successful redirect. Record redirect results with their own action so NOERROR address overrides are not presented or counted as blocked. Expose the new action in the Logs filter.
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.
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.
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.
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.
The post-build webhook returned success while ignoring the delivery because it omitted X-Gitea-Event: push. As a result, GitSource kept the prior revision and the published console image was never reconciled. Add the event header so the signed notification is queued only after the image has been published.
The relative Vite base made BrowserRouter pages request JavaScript and CSS under /auth/assets. The SPA fallback returned index.html for those paths, so the browser never mounted React and the sign-in page appeared blank. Emit root-relative asset URLs so every client-side route loads the embedded console bundle.