Commit Graph
19 Commits
Author SHA1 Message Date
mats a3d1614dca rework
build / build-push (push) Successful in 1m8s
2026-10-16 11:13:13 +02:00
matsandClaude Sonnet 5.5 7fa48bcdf9 Cluster UI: Blocklists management, locked config-file items, read-only upstreams; honour allow_config_write
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>
2026-10-16 10:56:21 +02:00
matsandClaude Sonnet 5.5 7d58b80583 web: commit the in-progress UI polish as found in the working tree
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>
2026-10-16 10:48:19 +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 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
mats 0b6dcbd547 overrides
build / build-push (push) Successful in 29s
2026-09-28 16:25:55 +02:00
mats 98b65945f6 Label DNS policy redirects accurately
build / build-push (push) Successful in 30s
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.
2026-09-28 15:48:07 +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
developer b2cd7d4b91 mroe ui 2026-09-28 10:37:15 +00:00
developer d94e4cf6a8 Give the zone-file editor something to look at
build / build-push (push) Successful in 32s
A textarea holding a master file is a text box: it says nothing about what the
resolver will answer for the zone, and a typo in a record is invisible until a
client sees the wrong answer. `/api/zones` already knows — it is compiled from
the same files — so Local zones now shows the records the daemon has actually
compiled out of the selected file, with the SOA serial and the number of
delegations, above the editor that writes it. It is the same list the Zones
page shows, which is the point: the two pages are two views of one thing, not
two ways of guessing.

The Zones page gains the way back: a zone that comes from a master file rather
than the managed directory has an "Edit the file" button that goes to Local
zones, so a read-only row is a link rather than a dead end.
2026-09-28 08:13:06 +00: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 52ec290055 Fix console assets on nested routes
build / build-push (push) Successful in 29s
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.
2026-09-27 22:51:04 +02:00
mats 2348d2e4b4 add
build / build-push (push) Successful in 30s
2026-09-27 22:32:03 +02:00
developer 70d85eac59 Bring the console onto the contract the Go side now pins
build / build-push (push) Failing after 25s
internal/app/contract_test.go asserts the exact JSON key set of every admin
endpoint, and the console was reading fields that shape does not contain —
which is how a page can be entirely blank while the Go suite is green. The
types file is rewritten against the canonical shapes and every consumer is
fixed onto it.

The page bugs that came out of it:

The cache "Lookups" column read `count` and "Last seen" read `last_seen`; the
cache tracks a hit counter and a byte count per name and sends `hits`, so
there is no last-seen data to show at all. Lookups now reads hits, Last seen
is gone, and the row's bytes are shown. The "Size" card shows bytes_human,
with size as the entry-budget hint it actually is.

The Sources table read `item.enabled`, `item.rules`, `item.bytes`,
`item.updated_at` and `item.err`; those live under `item.meta`, so the table
was blank and the per-row toggle posted `name: undefined`. The refresh toast
counted the names echoed back and said "every source" for an empty list.

The dashboard read `qps` and `total_queries` where the server sends
`queries_per_sec` and `queries_total`, and rendered a `latency`/`p99` block
the dashboard response never contained. The `Partial<Dashboard>`-and-cast
that papered over this is gone. `blocked_pct` arrives as 0..100 from REST and
0..1 over SSE, so formatPercent is 0..100 only and a new formatFraction
handles the SSE frame.

Logs rendered `qtype` as a string; it is a number, with `qtype_name`
alongside, so there is a formatQtype that prefers the name. The Domains page
read `item.list` where the server sends `source`, and showed an Updated column
with no timestamp behind it.

Upstreams treated `healthcheck_interval` and `breaker_cooldown` as numbers of
seconds; they are duration strings now, so the page parses them like the
bootstrap refresh it already handled and the write payload no longer sends the
runtime health fields the server ignores. A group edit stripped the four
server-owned counters before writing, because config.Group would reject them.

`whoami.can_write` and `whoami.read_only` were fetched and never used, so a
read-only API key still showed every Save control. A single whoami fetch now
lives in a session provider behind the app shell, every write control is
gated on it, and a banner says why when the key is read-only. It fails open:
an unreachable probe must not lock an operator out of their own console.

Two server fixes the console work exposed, both pinned by the contract test:

POST /api/blocklists/refresh with a body that omits `names` answered
{"names": null}, and the console counts the names it gets back to build its
toast — so "refresh everything" said "0 sources". A nil slice now becomes an
empty one and the key set does not depend on the request.

blocklist.SourceMeta.duration was a raw time.Duration, so it went out as a
nanosecond count and the console divided by 1e6 to compensate — the only
duration on the wire that was not a duration string. It is a config.Duration
now, and formatDuration parses it like the others.

The contract test grew to cover what the first pass left out: the domain
check in all three of its shapes (match, miss, and an error delivered as 200),
the log clear, the settings validate in both the valid and the malformed case,
the settings save and reload, and the first SSE frame.
2026-09-27 18:53:43 +00:00
developer e8c5b414d9 half broken
build / build-push (push) Failing after 23s
2026-09-27 17:17:53 +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