Technical overview

How a question is answered

One synchronous, allocation-free fast path per CPU core. Anything that has to wait goes async.

Query pipeline: receive, parse, policy, cache, upstream, respond ① Receive recvmmsg batch per-core socket ② Parse zero-copy, ~53 ns malformed → FORMERR ③ Policy client, group, filter lookup p99 < 1 µs ④ Cache hit ≈ 153 ns 0 allocations ⑤ Upstream hedged, deduplicated UDP · TCP · DoT · DoH ⑥ Respond sendmmsg batch TC=1 if too big fresh cache hit: answered on the worker thread, with no async work and no allocation upstreams slow or down: serve the expired answer (TTL 30 s, EDE 3) while the refresh continues

Threads

Every core on its own

Each worker thread owns a SO_REUSEPORT socket, so the kernel spreads clients across cores with no shared queue. Workers meet only at the cache, which is split into 64+ independently locked shards. Cache misses, TCP, DoT, and DoH run on a small async runtime.

Measured: 3.58× throughput going from 1 to 4 workers.

Per-core UDP workers share a sharded cache; misses go to the async runtime kernel (SO_REUSEPORT hash) worker 1 worker 2 worker 3 worker N cache shards async runtime (misses, TCP, TLS) upstream pools · singleflight
A cache hit copies the stored reply and patches only ID, case, TTLs, and EDNS stored once (ID 0, no OPT, TTL offsets remembered) ID flags question answers (TTL ▮ ▮) memcpy per client, in place their ID RD, AD their CaSe TTL − age OPT • Hash collision? The full name is compared, so it's a miss, never a wrong answer. • Eviction: S3-FIFO beats LRU by 4–11 points on Zipf traffic. • Negative answers (RFC 2308) and SERVFAIL (RFC 9520) are cached too.

Cache

Store the reply, patch the bytes

Instead of decoding and re-encoding, TelltaleDNS keeps the upstream's reply as raw bytes and remembers where each TTL lives. A hit is a copy plus a handful of small writes.

Upstreams

Hedged, not hopeful

If an upstream hasn't answered within about 3× its usual time, the next one is asked in parallel. The first good answer wins. A server that fails three times in a row is benched, then probed again later, and probes never make a client wait.

With one of two upstreams completely dead: 0 failed queries, and p99 within 1.04–1.15× of normal.

Timeline: upstream A is silent, B is asked after the hedge delay and answers 0 mshedgetimeout A silent… (finishes in background → health marks it down) B answers ✓ client gets the answer here, not at the timeout
Rule precedence: important allow, important block, allow, block. The first tier with a match decides, and explain shows every match. explain ad.doubleclick.net for the tablet (kids group) 1 · allow $important 2 · block $important 3 · allow 4 · block * family-allow:1 @@||doubleclick.net^ decides: allowed stevenblack:7102 0.0.0.0 ad.doubleclick.net adguard-dns:179140 ||doubleclick.net^ • Within a tier: exact name > deeper suffix > regex > list order. • Each group picks its lists; CNAME targets are checked too. • Lists are recompiled in the background and swapped in atomically.

Blocking

Blocking you can explain

Hosts files, AdBlock-style lists, and Pi-hole regexes are compiled in the background into one compact snapshot: about 9 bytes per blocked name, and a lookup takes under a microsecond. A new snapshot replaces the old one atomically, without pausing a query, and a recompile under load left p99 latency unchanged in our tests.

Every decision names its rule. telltale explain shows who a device is, its groups, every rule in every list that matches (with its line number), and which one decides.

Query log

Every query, searchable in a second

Each answer leaves a record: device, name, outcome, the rule that decided, and timings. The records are stored as one compressed file per hour, about 14 bytes per query, written in large sequential batches that are kind to SD cards. If the disk falls behind, records are dropped and counted; answers never wait for the log.

A search looks at each hour's list of names before it touches any rows, and skips hours that can't match. On a Raspberry Pi 4, finding a rare name among 50 million queries from the last 30 days takes about 0.2 s. Searches run at the lowest CPU priority, so DNS always comes first.

Search: hours are skipped by a name filter, then only matching blocks and the needed columns are read search: needle.example.org, last 30 days 09:00 10:00 11:00 12:00 … 720 hours 1 · each hour's name filter: "can't be here" → skipped unread names (dictionary) time name client status type … 19 columns 2 · match names once per hour, then read only the columns the filter needs • 50M queries ≈ 690 MB; a crash loses at most the last 10 s of detail. • Kept 30 days and 2 GiB by default; four privacy levels, down to none. • Every block is checksummed; damaged files are skipped, never fatal.
Three ways to sign in, one role check: browser sessions with a CSRF token, scoped API tokens, and opt-in HTTP Basic for scrapers Browsercookie + CSRF token Scripts, agentsBearer tt_… (scoped) Scrapers, widgetsHTTP Basic (opt-in) role check viewer · operator · admin • No default password: a one-time setup token creates the first admin. • Argon2id passwords, optional TOTP, lockout after repeated failures. • Secrets are stored only as hashes.

Sign-in

Locked until you set it up

There is no default password. On first start TelltaleDNS creates a one-time setup token, readable only by its own user, and whoever has it creates the first admin. Kubernetes and Ansible installs can create that admin from a secret instead.

The web UI is part of the binary, about 90 KiB compressed: a dashboard, a searchable query log with a "Why?" button on every row and a live view that streams queries as they happen, and pages for lists, devices and groups, upstreams, names on your network, the cluster, users, and API tokens. It follows your light or dark theme and works on a phone. Charts reach back 400 days from a small on-disk history, and a ready-made Grafana dashboard covers the Prometheus metrics.

People sign in with a password and, if they like, an authenticator app, or with the identity provider they already use (Keycloak, Authentik, any OpenID Connect provider), which also decides their role. Scripts, dashboards, and AI agents get their own tokens, limited to reading if that is all they need. Prometheus and home dashboards can use HTTP Basic, but only for accounts where an admin turned it on. Each user is a viewer, an operator, or an admin, and every change they make lands in a tamper-evident audit log.

Clustering

One cluster across a Pi and Kubernetes

Hybrid cluster: a Raspberry Pi and Kubernetes pods, one management plane 🏠 Raspberry Pi TelltaleDNS (all-in-one) local snapshot · keeps answering offline ☸️ Kubernetes controller resolver resolver scales horizontally · real client IPs preserved mTLS · signed config snapshots manage & analyze from either side
Signed configuration replication, one dashboard and query log across every node, changes from any node's UI (forwarded to the primary), manual or elected failover with fencing, and CA rotation without downtime. DNS answering never depends on the cluster being healthy: every node serves from its last good snapshot. Taking a node down for work? Maintenance mode makes it step out of the load balancer for a set time while it keeps answering, pauses its alerts, keeps it out of elections, and hands the primary role over first when the cluster can. Staged rollouts, when you name canary nodes, give each change to them first and to everyone else after a bake; if the canaries start failing, the cluster is pinned back to the version before, and any kept version can be pinned by hand.

Also built in

The rest of the toolbox

DNSSEC

Validates signatures itself, with negative trust anchors for broken domains, and says why a lookup failed (extended DNS errors).

Serve-stale and prefetch

Popular names are refreshed before they expire, expired answers keep flowing while upstreams are down, and the cache survives a restart.

Encrypted DNS for devices

DNS over TLS and HTTPS listeners with certificates reloaded on the fly, and PROXY protocol v2 so a load balancer doesn't hide clients.

Moving in

telltale import pihole reads a Teleporter export; telltale import technitium reads a live server. Both write a config file with notes on what didn't carry over.

Backups

One .ttbk archive with your configuration, users, devices, audit log, and statistics history (the query log optional), checksummed so a restore verifies every file before it writes any.

AI agents

Agent tokens with narrow scopes, a reason on every change, rate limits, and a kill switch. An MCP server (/mcp) gives assistants tools to look around and to plan changes, which take effect only when applied, and only after you approve them if you want that.

Versions and updates

Every build knows its version and commit, checks a signed release index daily (optional), and tells you how to update for the way you installed it.

Anomalies

Learns each device's normal pattern and points out query spikes, bursts of new domains, and regular phone-home beacons, with the evidence. Findings never block anything on their own.