• v0.0.9 03149b5d65

    v0.0.9
    Release / guard (push) Successful in 36s
    Gates / frontend (push) Successful in 2m3s
    Gates / test (push) Successful in 2m18s
    Gates / test-aarch64 (push) Successful in 7m41s
    Gates / package (push) Successful in 4m20s
    Gates / container (push) Successful in 10s
    Release / gates (push) Successful in 19m21s
    Release / publish (push) Failing after 4m49s
    Stable

    mokhtar released this 2026-08-30 08:52:51 +00:00 | 48 commits to master since this release

    Query provenance: every logged query becomes exactly explainable — what the policy decided, what matched, where the answer came from and what the client saw. The handler records all of it as the reply goes out, query_log stores it, and a detail page reads one query back in the order the pipeline decided it. Read the upgrade note below first: it resets your query history.

    Added

    • Every logged query has a detail page. A row in Activity now links to /activity/queries/{id}, which explains that one query in the order it was decided: the request, the group it was matched under, the policy verdict with the rule that produced it and the blocklist source that rule came from, any CNAME uncloaking or safe-search rewrite, the route the answer took — blocked, local, forward zone, upstream or cache — and what the client got back, RCODE and duration included. GET /api/queries/{id} serves the same object; an id that retention has already deleted is a 404. The live view carries the same provenance for the queries it streams, so a query is explainable as it happens as well as afterwards.
    • Query Log, Live and Lookup are one Activity page. /activity is the single surface for what nxdns answered: History reads the stored log, Live reads the stream, and both show the same seven columns — Time, Domain, Client, Type, Result, Route, Duration. The mode and every filter live in the URL, so an investigation is one link that shows the recipient exactly what you were looking at, and an absolute time range stays that range instead of drifting as the day goes on. A new Result column says what the client actually got — Blocked, NOERROR, SERVFAIL and the rest — with the Route column beside it saying how the answer was produced, which is the pair the old Status column could not show: a blocked name is answered with NOERROR, and reading only the code made a block look like a success. Both unhappy cases are marked by weight and shape as well as colour. Switching between History and Live keeps your filters, and leaving Live closes the stream instead of holding a viewer slot open. A live row that the log has not written yet opens its own provenance in place — no invented row id — and the open detail stays put while the 500-row buffer scrolls past underneath it. Domain testing moves to /activity/test as Current policy simulation, worded so it can never be misread as an account of a query that already happened.
    • Diagnostics can be scoped to an absolute window. /diagnostics?since=…&until=… now validates and applies both bounds to the active and resolved lists, and the page states the window it is showing with a way to clear it. A query's detail page links here with the five minutes either side of that query, which is where the underlying failure text for a SERVFAIL lives.
    • The query log and the stats endpoints say how far back the history goes. GET /api/queries and all five /api/stats* endpoints each carry a coverage object: available_since, the first second the file can answer for, and complete, whether the window you asked for begins inside it. A period that starts before the query log does now says so instead of charting the missing part as zero — which is what a recreate, a retention pass or a fresh install would otherwise look like.
    • Three new period breakdowns: GET /api/stats/types, /api/stats/routes and /api/stats/clients. They take the same period parameter as /api/stats and report over the same UTC-aligned window, so every panel of one page describes the same span. types counts queries per DNS type, with the queries that recorded no type kept as their own row instead of dropped — the numeric type only, since naming types is the admin's job and a second table in the server would drift out of agreement with it. routes counts queries by how they were answered, grouping upstream rows by the answering resolver and forward-zone rows by the zone, with blocked, cache, local and rejected answers carrying no source. clients returns one bucketed series per client, aligned exactly like /api/stats/timeseries so the two charts share an x-axis: the eight busiest clients in the window are named and everything else sums into an other series, which is always present and always the same length as the named ones.
    • /metrics says whether an upstream is queueing. Five new per-upstream series, labelled by index and redacted url like the existing ones: nxdns_upstream_in_flight and nxdns_upstream_slots are the exchanges in flight against an upstream and the ceiling they cannot cross, nxdns_upstream_queued_total and nxdns_upstream_queued_seconds_total count the exchanges that had to wait for a slot and the time they spent waiting — including the ones that were cancelled while waiting, which is exactly the query that ends in SERVFAIL — and nxdns_upstream_reuse_recoveries_total counts the stale DoT connections that were redialled, so connection churn is a number instead of log noise. The two queue counters are approximate: they are sampled when a query is admitted, not measured as a queue length. /api/health is unchanged.
    • Each of those responses is read atomically. Every window-bounded read — the five stats endpoints and GET /api/queries — now takes its rows and its coverage watermark inside one SQLite read transaction. A retention pass that runs mid-response can no longer hand back rows from before the prune tagged with an available_since from after it, and the clients breakdown ranks and buckets from one database state rather than two. The transaction is a deferred read, so it never blocks the query logger or retention.

    Removed

    • The upstream-history subsystem and GET /api/upstream/health are gone. nxdns recorded every upstream exchange into per-minute aggregates in querylog.db so the dashboard could show each upstream's counts, success rate and last failure over the selected period. The Overview replacement drops that table, which left a writer whose only reader was its own failure signal, so the whole subsystem goes: the accumulator and its flush task, the upstream_targets and upstream_minute tables, the /api/upstream/health endpoint, the retention pass over the minute rows, and the four nxdns_upstream_history_* and one nxdns_retention_upstream_rows_pruned_total Prometheus metrics. What replaces it: /api/health says how many upstreams are available of how many enabled, /metrics keeps the live per-upstream nxdns_upstream_up and nxdns_upstream_success_rate series, and a failing upstream is a Diagnostics episode (upstream.exchange) with its own error text and duration. Ranged per-upstream counts are not replaced. Existing upstream_history.write diagnostics entries stay readable; nothing writes new ones, and any that were still open when you upgrade are closed at the first start.
    • /queries, /queries/{id}, /live and /lookup are gone, and bookmarks to them break. There is no redirect and no alias: the paths simply stop resolving, and the app shows its not-found page. Everything those pages did is on /activity, /activity/queries/{id} and /activity/test. Three navigation entries collapse into one, "Activity". The API is untouched — /api/queries, /api/queries/{id}, /api/queries/live and /api/lookup all answer exactly as before.
    • The Status column, and the block reason on every row. The reason a query was blocked was repeated on each of a hundred rows and pushed the answer the client saw off the table. Result and Route replace it; the exact rule, the blocklist source and the historical group stay one click away on the query's detail page, which is the only place they were ever readable.

    Changed

    • Configuration is three task-shaped pages, and a file-managed server says so once. Groups, Blocklists, Rules, Local DNS, Upstreams and Settings — six navigation entries and six routes — become /configuration/protection, /configuration/resolution and /configuration/system under one labelled "Configuration" group. Protection is group-centred: pick a group and read its safe search, its blocklist sources, its rules and how many clients it filters, with the shared blocklist catalogue and "Update now" on a second tab. Resolution holds the upstream pool, local records and forward zones; System holds the settings sections, the restart-required annotations and "Reload certificates". The tab and the selected group are URL state, so the view you are reading is a link, and the six old paths stop resolving — no redirect, no alias. When a configuration file owns the server, these pages are no longer forms with everything greyed out: they render as definition lists and tables with the exact ZON key beside each value, a note saying which file to edit, and no input, checkbox, Add, Edit, Delete or Save anywhere — while runtime actions that do not touch the file (update blocklists now, reload certificates, pause and resume, deleting an observed client) stay ordinary working buttons. A new GET /api/config/status is the one place that answers who owns the configuration and whether a restart is pending; the admin never guesses from a 403, and until the endpoint answers nothing is editable. restart_pending is now the server's own state rather than a banner one browser tab remembered: it is set by the upstream and settings changes that need a restart, it survives a page reload, another tab sees it within a minute, and only restarting the process clears it. GET /api/settings loses its authority object, which said the same thing twice. Clients keeps its own top-level page, because naming an unknown device is operations, not configuration: the list is address, name, group and first/last seen, ?group= filters it to one group, and each row opens /clients/{id} — the client's identity and where its name came from, the group whose policy it follows with a link to that group, and a link into Activity for its last 24 hours.
    • The Dashboard is now Overview, and it takes Pi-hole's layout. / redirects to /overview, and the page answers one question — what the resolver did over a period you choose — instead of laying out six widgets. The 1h/24h/7d/30d period is URL state (/overview?period=1h), so the view you are reading is a link you can send. Top to bottom: four neutral stat tiles — queries, blocked with its share, distinct clients, average response time — each linking into the rows behind its number; the query-volume timeline split blocked, cached and other; a new per-client chart on the same axis, the busiest clients named — by their registered or reverse-DNS name where they have one, exactly as the query tables name them — and the rest summed as "other"; and two donuts, query types and how queries were answered, with each upstream and forward zone named separately. Colours follow the identity of a client, a type or a route rather than its rank, so one client overtaking another between refreshes does not repaint the page. Each donut is drawn as decoration with a visible legend beside it and a table a screen reader reads instead of the graphic. All five panels describe one window — matched on the period, both bounds and the coverage watermark together — so a refresh that straddles a bucket boundary, or a retention pass mid-page, can never put a headline count above a chart of a different span. Panels load, fail and retry on their own: a failing donut leaves the charts standing. A period with nothing in it says "No queries in this period." rather than drawing an empty frame.
    • The five health conditions moved to Diagnostics, and the nav item says when to look. Protection, Upstreams, Query history, Diagnostics and Storage are now a compact strip at the top of /diagnostics, above the episodes that explain them, instead of a status list on the landing page. Each states its state in words and an icon as well as colour. A healthy condition is quiet; a degraded one is highlighted and links to what can fix it: protection to Blocklists, no reachable upstream to Upstreams, and a losing or failed query log or a low or critical disk to this same page filtered to the component that failed, with any time window cleared so the filter cannot hide the episodes it points at. Dropped rows are reported with the time of the newest drop, so a loss stays visible after the box recovers. When a health poll fails, the conditions on screen are labelled as the last reading that arrived rather than passing for the current state, and a Retry sits beside them. The Diagnostics navigation item carries a badge with the number of open episodes; it shows a plain "!" when the rollup is degraded with nothing open, and also when the last health poll failed, because an unknown must not look like good news. The badge is absent only when health answered and there was nothing to report.
    • Pause moved to the sidebar, and the header indicator is gone. The header carries nothing but the menu button and Log out. Pause and Resume sit at the foot of the navigation sidebar, above the version label, in both the desktop rail and the phone drawer — one global runtime action in the one place that belongs to the resolver rather than to whichever page you are on. The control still appears beside the detail of a query that was blocked, which is the other place the action answers what you are looking at. The control says what it is doing as well as what it offers: Pause while filtering is on, and while it is off, "Paused until 14:05" — or plain "Paused" when the pause has no end — above the Resume button, on every page. "Resume" on its own would name an action without naming the state it ends, and with the header indicator gone no other page could tell you filtering was off. Both controls read the same protection condition, so they cannot disagree, and a pause or resume is reflected immediately rather than at the next poll. Nothing offers to pause while protection is unavailable, since pausing a resolver with no filter snapshot changes nothing.
    • GET /api/health changed shape completely. The body is now status plus five condition objects — protection, upstreams, query_history, diagnostics and disk — and status is degraded when, and only when, one of them is in a degrading state: protection unavailable, upstreams unavailable, query history losing or failed, diagnostics unavailable, or disk low or critical. Nothing can degrade the rollup without appearing in the response any more; the old hidden upstream-history contribution was the reason for the rewrite. A paused protection is reported and does not degrade, because it is a choice you made rather than a fault. queries_dropped and writer_failed fold into query_history, which also carries last_drop_s, the time of the newest dropped row; refreshes_gated and snapshot_generation leave the body and stay in /metrics, as do the disk db_bytes, log_bytes and sample_failures fields. The disk monitor's warn state is reported as low, because warn reads as a log level rather than as a quantity of disk. This is a breaking change to a documented endpoint, taken pre-v0.1 rather than carried.
    • Upgrading resets your query history a second time. Dropping the upstream_targets and upstream_minute tables changes the querylog.db schema fingerprint, and that file is never migrated, so the first start after this release sets the old one aside as querylog.db.schema-changed-<unix seconds> and creates a fresh one — exactly as the provenance change above does, and in the same start. config.db is untouched.
    • GET /api/stats no longer reports cached. The standalone cache card is gone from Overview, so the totals field behind it has no consumer. Cache hits stay visible in the query-volume timeline's blocked/cached/other split, in GET /api/stats/timeseries, and in nxdns_cache_hits_total.
    • GET /api/queries rows changed shape. Each row gains qclass, rcode, policy_action, policy_reason and route_kind, and block_reason is gone: the reason a query was blocked is now one of a closed set of values rather than a formatted string. No table column shows it — the reason is read on the query's detail page, and by an API client from policy_reason on the row. blocked, cache_hit, upstream and every other existing field are unchanged.
    • Upgrading resets your query history. The query_log table gains the provenance columns below, and querylog.db is never migrated (it holds expendable log rows, so a schema change replaces the file instead of upgrading it). On the first start after the upgrade the old file is set aside as querylog.db.schema-changed-<unix seconds> and a fresh one is created. Nothing else is touched: config.db keeps your configuration and your diagnostics history. The recreate files a resolved query_log.recreated diagnostics entry naming the file that was kept and the timestamp the new history begins at, and a new querylog_meta table records that coverage start, so the dashboard can say "history is available from ..." instead of charting an empty range as zero. The set-aside file is a working SQLite database and can be deleted once you have decided you do not want it.
    • logging.query_log_buffer_max now accepts 1 to 37449, down from 1 to 1000000. The queued entry carries every new provenance field by value and is about four times as wide as before — 1792 bytes against 432 — so the meaningful bound is bytes rather than entries. The ceiling is computed at compile time from the width of the entry so that the queue's worst case stays within 64 MiB, and it moves whenever that width does. The default of 10000 is unchanged and costs about 17 MiB. A configuration above the new ceiling is rejected at startup with the ceiling in the message.
    • Group and blocklist source names are now capped at 64 bytes. Both are copied into every query-log row that mentions them, so an unbounded name was an unbounded cost per row. A longer name is rejected as GroupNameTooLong or SourceNameTooLong.
    • The query log returns to SQLite's default checkpoint cadence. 0.0.8 stretched wal_autocheckpoint on every read-write querylog.db connection from the 1000-page default to 8192 pages, on the expectation that it would cut about 130 MiB a day of checkpoint writeback on the deployed Pi. Field measurement on that Pi showed no measurable effect on daily disk writes, so all it bought was a roughly five-hour power-loss durability window in place of the default's ~40 minutes. No pragma is issued any more: the cadence is SQLite's 1000 pages, about 4 MiB, and the ~40-minute boundary is back.
    • The admin bundle now has a ceiling the build enforces. npm run build fails if admin/dist/assets totals more than 800,000 bytes — it is 708,352 today — and prints the largest chunks when it does. The bundle is embedded in the server binary and served to your LAN, so an accidental dependency arriving in it is a regression every other check would have passed. Alongside it the redesign's closure sweep removed the last code the new pages left behind: an unused API client call and type, and the features/queries directory renamed to features/provenance now that no page lives there. The investigation links that carry a time window out of a query detail are pinned by their own tests, including one that a link's emitted bounds survive the Activity page's validation unchanged. Nothing an operator uses changed.

    Fixed

    • A burst of concurrent queries no longer resolves one at a time, and no longer ends in SERVFAIL. The pool held an upstream for the whole of an exchange, so every query against one upstream waited for the one before it, and the DoT client dialled a fresh TCP connection and ran a full TLS handshake for each query on top of that. Thirty concurrent names against one DoT upstream resolved as a staircase at about 92 ms per query on a Pi, and the queries at the back of the queue burned the five-second total budget waiting and were answered SERVFAIL — with nothing on any surface saying a queue existed. Two changes fix it: each upstream now runs up to eight exchanges at once, each on its own leaf client behind a semaphore, so a query waits for a free slot rather than for the whole upstream; and a DoT client keeps its TLS session open across exchanges instead of handshaking per query. A reused connection that the resolver closed while it was idle is detected at use — never by a keepalive timer — and redialled once, and that redial is invisible to health and to Diagnostics because an idle close is normal, not a fault. The slot count is compiled, not configured: there is no new knob.
    • A UDP reply that has to be truncated keeps the answer's RCODE. When an answer does not fit the client's UDP buffer, nxdns replaces it with an empty reply carrying the TC bit, which tells the client to retry over TCP. That replacement was always built as NOERROR, whatever the answer said — so an oversized NXDOMAIN reached the client as a success, and an EDNS extended RCODE above 15 lost the eight upper bits it needs an OPT record to carry. The truncated reply now carries the full twelve-bit code the answer had, split across the header and the reply's OPT record where the code needs it, and the query-log row records the code the client actually saw. The retry over TCP always returned the right RCODE; this was the UDP answer that preceded it.

    Artifacts

    6baeb1b9695453d2b4cdebde6ab372f69793a65ad68644d2f344b698c6e12990  nxdns-0.0.9-x86_64-linux-musl.tar.gz
    60be07dc0129a59ed6cee73dbaafaebec90083e16ff682fbd038250eb6f24594  nxdns-0.0.9-aarch64-linux-musl.tar.gz
    cfc2d1d015b40f91bf032ad68c6e188e390a73802e307bdc2f0d44c42675b7b4  IMAGE-DIGEST.txt
    
    git.mial.net/mokhtar/nxdns:0.0.9@sha256:64af1d3655e8514dd5d212ae80bad35cf1f16ec0e859c38e3071341aecc4ec38
    

    Compare v0.0.8...v0.0.9

    Commits since v0.0.8
    03149b5 ci: drop the one-insn-per-tb workaround, the qemu panic is an 11.1.0-only regression
    a972c8d logger tests: join every writer future before its database closes, fix the gate-open race
    78d688a ci: run the aarch64 suite with one qemu insn per tb, tcg optimization falsely trips the ubsan pointer check
    fe5bd96 admin: live ring capacity is injectable, eviction test no longer timing-bound
    1f215ac build: bump version to 0.0.9
    2f86cd7 changelog: 0.0.9 releases today
    51cda8b milestone 33: contract closure — samples, file-authority enumeration, dead code, bundle ceiling
    e5f23d5 querylog: return to the default checkpoint cadence
    24521ab milestone 32: task-shaped configuration, file mode as a rendering, config status api
    c99a37d milestone 31: concurrent upstream exchanges, dot session reuse, queue metrics
    623667e milestone 30: overview as a dashboard, explicit health contract, period aggregations
    0e83477 agents: move opus-coder to the global scope
    fa323c7 milestone 29: activity — history, live and policy simulation on one surface
    0fd6bbd milestone 28: query provenance — every logged query is exactly explainable
    7e6cb50 release cut: bump-kind justfile recipe and a compiled, tested cut tool
    8a17e9e admin: fix dashboard phantom scroll, drop last-failure column from upstream table
    0107df5 spec: measurement window is the deployment side's call
    
    Downloads