Honest version history. Feature flags, design rollbacks, security fixes — all of it. If we shipped something only to walk it back, you'll see that here too.
A JWT stayed valid for its full 24 hours no matter what happened to the user behind it. Deleting them did not revoke it, changing their password did not revoke it, and demoting an admin did not revoke it. The account-recovery path was the worst of the five cases: resetting a password invalidated other reset tokens and left the intruder's session running.
Fixed at the request extractor with a per-user session generation, so it applies to every endpoint rather than to the one handler that had guarded against it by hand. Measured in both directions: before the fix, a deleted user's token minted a working API key; after, it is refused.
Twenty machines imaged from one Windows image all picked the same device name, and a deterministic retry let exactly one through per round — a 20-machine rollout needed 20 rounds. Now a single atomic insert that picks a random free suffix on retry.
Join-code use limits were enforced by the query and never written by the API, so every code was unlimited-use. Wired end to end, and a use is refunded on any failure after consumption so a bounded code does not shrink on a lost race or a hostname typo.
Join codes let a machine register itself and wait for an administrator to approve it. The endpoint's DNS is untouched until approval, so waiting costs the employee nothing, and the code grants nothing except the right to ask.
Change-password never verified the current password — proven on the running build: a wrong password returned 200 and rotated it anyway. Separately, a device whose resolver mapping failed was still admitted and resolved on the global filter set forever, and the config renderer would overwrite a populated resolver config, wiping tenant routing in one command.
Webhook event names are validated against an allowlist (any string was accepted, so a typo was a silent dead subscription). Sessions became revocable, and password policy became a breach list rather than composition rules.
The edge access log was recording customer browsing history: an RFC 8484 GET puts the whole DNS question in the query string, and the DoH path is itself the device credential. Both are now redacted at the proxy. Two of the report's three “High” findings were false.
The API now reports DoT and plain DNS as unavailable rather than naming endpoints. Plain DNS was closed in v0.57, and DoT stopped carrying tenant identity at the v0.66 admission cutover. Tenant identity is the DoH path component, and DoT and DoQ have no path.
An empty allow-list in the resolver means “all clients”, not “no clients”. Any fabricated DoH path resolved, served with the global filter set — including a deleted device's. Deleting a device returned 200 and revoked nothing.
One module is now the sole writer of that access document, and it refuses rather than guesses: it will not empty the list, will not remove more than half the clients in a single run, and records every decision to a table.
The report's headline finding was self-inflicted by its own test harness, and its tenant-isolation claim was disproved by measurement. What was real and not in the report: a workflow tool that advertised category blocking it never called, wildcard block rules that failed on every call because the API's own documentation promised syntax it rejects, and any device name over 25 characters producing a DoH URL that could never resolve.
A major upstream blocklist account disappeared for 36 hours and then came back. Upstream is now an input rather than a dependency: fetch conditionally, validate, store an immutable snapshot named by its own hash, and serve the promoted one. A failed or refused fetch leaves what is being served byte-identical.
The OIDC flow was correct end to end and finished by handing the browser a token in a URL fragment that nothing read. Every server-side step logged success and the user got a blank page with a valid session thrown away.
A detection dictionary of AI services, an endpoint that surfaces unclassified AI-looking traffic, and AI as a blockable category. Detecting a tool and being able to block it are different questions and are now different fields — blocking a shared cloud API host would take unrelated services down with it.
Nothing had ever written the readiness column, so all five categories reported unavailable and every attempt to enable one returned 400. The feature was unreachable by construction. Categories are now an operator opt-in, because the resolver's filter list is global and every registered feed slows policy enforcement for every tenant.
The newly-registered-domain toggle was a live feature backed by nothing; it now reports unavailable and refuses to be enabled. Region-branded entries that pointed at generic lists were deleted, five were repointed against their upstream titles, and a liveness checker records health per list. It found two lists that return HTTP 200 and are useless.
Plain DNS was being used for reflection: 16.4 million queries for a single record from 7,665 source addresses, peaking at 12,209 queries a minute. It served no customer, because tenant identity requires the DoH path, so it is closed.
An environment variable that is set but empty is not unset, so the fallback never fired. The resulting URL was schemeless, every filter list failed to install, and the cleanup phase then removed the ones that were working. Tenants resolved unfiltered until it was caught.
Reconcile is now fail-closed, serialised on a database lock, and records every run with counts and a reason — container logs do not survive the recreate that is the first thing anyone does during an incident.
An unparseable date reached the analytics store verbatim, and an absurd hours value produced a negative year. Both now return 400 or clamp.
A dependency licence, advisory and provenance check runs as a build step, before the tests, so a policy violation cannot reach a deploy. Also fixed a blocked-services schedule that had never worked.
A local DoH forwarder for Windows 10 and a configurator for Windows 11's built-in DoH client. The DoH URL is the device credential, so no API key goes on an end-user machine: a lost laptop leaks one device identity, revocable by deleting that device.
Per-credential rate limits, a self-service DNS check endpoint, and coherent version reporting across the API, the spec and the binary.
The DoH identifier is a bearer credential for the data plane, so it is now rotatable: rotation retires the old identifier, mints a new one and rewrites every device URL. Analytics survives, but only for queries logged before the rotation, so a leaked identifier cannot keep injecting rows into the victim's reports.
Nightly backups had been silently producing nothing for a day: one unquoted value in an environment file killed the script before its first dump, while the API read the same file through a different parser and looked healthy. Webhook targets are tenant-controlled and are now resolved and rejected for private address space, with redirects disabled.
The dashboard is read-only on purpose. Credential management is the single exception, because without it a self-serve tenant cannot reach the API at all except by reading a token out of browser devtools.
A scope says what kind of thing a caller may touch, never whose. Listing tenants had only a scope check, so every tenant admin — and a key literally named read-only — could read every tenant's resolver identifier, which is the credential that identifies them on the wire.
Partially reversed since. Terminating TLS for the API at a third-party edge meant it could see analytics responses and API keys in cleartext, and it overwrote the real caller address in the audit trail. The edge now serves only DNS and the static site.
Added 56 community blocklists across 5 new categories (AI scrapers, safety, additional regional/language sets). Closes the "smaller catalog than NextDNS" gap that was a real spec-sheet objection.
Integrity pass on the marketing site: removed claims we couldn't back — "ISO 27001 certified" (we're not), "HIPAA BAA on every plan" (we don't offer one), "FedRAMP in process" (we aren't), "4,200+ security teams worldwide" (zero customers today), the fabricated company timeline with the $74M Series B, and the named testimonial from a fictional Director of Security. Replaced with honest current-state copy and a design-partner call. Pre-customer / pre-revenue is the truth; the site now says so.
End-to-end test now reads DOM nodes directly (not regex over innerText), asserts exactly 3 QA devices with expected DoH URL shape, clicks the device "copy" button and reads navigator.clipboard back, parses the expanded audit row's <pre> as JSON, and runs a separate viewer-role test that PUT /policies/filtering must 403.
Added scripts/qa_data_seed.sh so the 24h analytics window stays full across days.
Alpine's x-for requires a single root per iteration. A leftover nested <template> meant no rows ever rendered, only the header. Replaced with <tbody> per row (HTML allows multiple tbody per table). Caught by the hardened Playwright spec from v0.33.
require_scopeThe scope gate returned Ok unconditionally for any human JWT and assumed "role checks apply instead" — but no policy handler called require_admin. A viewer JWT could PUT /policies/filtering, /policies/custom-rules, etc.
Fixed: human admins still pass every scope; viewers now only pass :read scopes. :write from a non-admin JWT returns 403. Added a regression unit test and the viewer Playwright test from v0.33 that surfaced it.
/versionSince v0.23 introduced per-device DoH (<tenant_uuid>--<slug>), the where_client helper's exact-match check missed every per-device row. Summary, top-domains, top-clients, top-ai-tools, blocked, by-reason, latency, block-rate all undercounted. Switched to LIKE '<tenant_uuid>%'.
Also: new GET /version returns git SHA + build timestamp so ops can verify what's actually running.
Three new read-only screens for surfaces that previously only existed in the API:
Bundled with the v0.23 device routing fix (AGH rejected / in ClientID; switched separator to --).
x-required-scopes + 5 MCP workflow toolsThe 5-minute reconcile cron now keeps the AGH global filter pool aligned with per-tenant subscriptions automatically (the v0.25 endpoint was admin-only manual). OpenAPI spec gains x-required-scopes on every secured operation. New high-level MCP workflow tools wrap common multi-step operations (onboard-tenant, triage-typosquat) for AI agents.
Two inline classifiers run at ingest with no vendor feeds:
/analytics/top-dga; tunable threshold (default 0.7); no auto-block./analytics/top-typosquat."Scenario A" — chosen over a vendor-feeds option for explainability + zero per-query cost. Future scenarios B/C remain on the table for higher-cost / higher-recall workloads.
The filter-list IDs that drive NRD/TLD/parked toggles were hardcoded in policies.rs. Moved to a feature_flags table so they can be migrated, re-numbered, and disabled per environment without a redeploy.
POST /blocklists/reconcile — drift-correct AGH from Postgres truthAGH's loaded filters[] is a single global list shared across tenants; per-tenant subscriptions live in Postgres. Reconcile diffs them and applies adds/removes idempotently. Admin-only (tenant 1). Pairs with the cron in v0.28.
Per-tenant toggle that flips DNS resolution from "block what's bad" to "allow only what's explicitly permitted." Implemented via a catch-all wildcard block rule with the tenant suffix, layered under existing per-tenant allow entries. The "Fortune-500 healthcare desktop" mode.
Devices are first-class: each gets a unique slug + display name, and AGH ClientID becomes <tenant_uuid>--<slug>. Every analytics endpoint can now answer "which device looked up X?" Pairs with the dashboard Devices view shipped in v0.29.
Note: v0.23 originally used / as the separator, which AGH rejects as an invalid hostname-label character. Fixed in v0.29 to use --; per-device queries were SERVFAIL until then. Caught by end-to-end QA, not by a customer.
Every audit event can fan out to a webhook endpoint. Bodies signed with HMAC-SHA256 over the payload using a per-webhook secret; timestamp + event-name headers; wildcard event matching (policy.*, threat.*, etc.). Backed by a single delivery worker with retry-with-backoff.
Three GDPR/DPDP-flavored controls per tenant:
All 33 MCP tools now carry hand-written when / why / sharp edges descriptions instead of auto-generated "Executes GET /api/v1/..." fallbacks. Measurably improves how AI agents (Claude, Cursor, Continue) pick the right tool on the first try.
The four configuration screens (Policies, Categories, Users, Settings) are gone. Configuration happens via REST API or via the MCP server — agents do the typing, humans review via the Audit Log.
−326 LoC of dashboard codeOne-click toggles wrap existing primitives. Turn on newly_registered_domains to subscribe to the Hagezi NRD blocklist; pass a TLD list to block whole suffixes via wildcard rules.
Future toggles (parked-domain, DGA classifier, typosquat) are reserved in the response shape but return null — they need real classifiers and feeds, landing in a future release.
The ControlD-style Redirect feature: override any DNS answer for any domain. Five kinds — A, AAAA, CNAME, NXDOMAIN, REFUSED. Coexists cleanly with custom block/allow rules.
Implemented via AGH's $dnsrewrite modifier joined to the existing per-tenant client tag.
GET /api/v1/analytics/export?format=csv|ndjson for SOC handover and audit prep. Cap 10M rows per callAll 35 API endpoints documented in the OpenAPI 3.1 spec at /api/v1/openapi.json. MCP server uses FastMCP's RouteMap(MCPType.EXCLUDE) to hide eight humans-only operations (login, password change, key mint/rotate/delete, tenant create/delete, user delete) from agents regardless of scope grant.
Agent-native config plane. FastMCP sidecar auto-generates tools from the OpenAPI spec, forwards the caller's X-API-Key on every upstream call, and respects per-key scopes end-to-end. Both stdio (Claude Desktop, Cursor) and Streamable HTTP transports.
Single source of truth for the API. Drives MCP tool generation, external SDK codegen, and human-readable docs.
GET /tenants/me — caller's own tenant (UUID, DoH URL, endpoints)PATCH /tenants/:id — rename + contact_emailPOST /auth/change-password — self-service, JWT-onlydoh_url no longer leaks :8443Foundation for agent delegation. Twelve resource:action scopes (analytics:read, policies:write, etc.); optional expiry; one-call rotation. Every audit row records actor type (user / api_key / system) and actor id — every config change is now traceable to a specific human session or a specific agent token.
Pre-v0.11 keys are auto-promoted to a legacy:full backstop scope for backward compatibility.
Alpine.js + fetch single-page app on login.olladns.com. API and dashboard now live on separate hostnames with proper CORS.
DNSSEC validation enabled on every resolver. sysctl hardening. Cloudflare token scoped to the single zone. fail2ban watching Caddy 4xx storms. Plain port 53 no longer exposed publicly — DDoS amplification surface eliminated.
Earlier versions (v0.1–v0.8) covered the initial DNS plane, AGH integration, ClickHouse ingest, per-tenant policy/blocklist subscriptions, audit logging, and the original v0.7 switch from Vector to a Rust AGH poller. See the git history for the full record.