So, I've decided to write my own DNS filter/forwarder but in RUST, in a distroless container.
What it is
A network filtering engine that sits between every device in a house and the internet. One container on the router, no client configuration, no browser extension, no per-device agent.
It filters DNS and HTTP today, HTTPS after that.
Performance is the primary feature. Every architectural decision is evaluated by its effect on throughput, latency and allocations — and the numbers below are measured on the target hardware, not estimated.
Status
| Phase | Scope | Status | Tag |
|---|---|---|---|
| 0 | Foundations, workspace, layering | v0.1.0-phase0 |
|
| 1 | DNS + REST API + Docker | v0.2.0-phase1 |
|
| 1.5 | Observability persistence | v0.3.0-phase1.5 |
|
| 2 | HTTP engine + Policies | — | |
| 3 | HTTPS interception | — | |
| 4 | HTML filtering | — |
Running in production on a MikroTik RB5009 as the household's only resolver, in dns+http mode. Phase 2's engine work is deployed — the transparent HTTP proxy, URL-path rules, per-client Policies and the single JSON telemetry surface. What remains open is a memory-transient investigation, not a feature.
Measured, on the RB5009
Quad-core ARMv8 @ 1.4 GHz, 1 GB RAM shared with RouterOS. Every figure comes off the deployed container, not a dev box. Measurements are binary (MiB); PERFORMANCE.md writes its budgets in decimal MB, which runs ~4.9 % higher for the same reading.
| Measured | Budget | |
|---|---|---|
| Resident memory, 666130 rules + 50 k-entry cache | 47.6 MiB | ≤ 128 MB |
| Peak RSS at boot | 117.8 MiB | ≤ 128 MB |
| Peak RSS during a list refresh | 171.7 MiB | see below |
| Ruleset compile, 1.15 M parsed rules | 2.85 s | < 3 s |
| Ruleset heap, resident | 25.8 MiB | ≤ 40 MB |
| Blocked verdict, in-engine | 0.045 ms mean | < 1 ms p99 |
| Cache hit, fresh + stale-while-refresh | 0.227 ms mean | < 1 ms p99 |
| HTTP proxy, added latency | +161 µs min · +344 µs p50 | < 1 ms |
| HTTP proxy, opaque throughput | 271 / 208 MiB/s | ≥ 100 MiB/s |
| URL verdict, 8 KiB URL, full EasyList+EasyPrivacy | 554 µs | < 1 ms |
| Sustained DNS throughput, deployed path | 20 k+ QPS |
≥ 10 k QPS |
| Container image | 13.0 MiB | ≤ 30 MB |
| Dropped events under real load | 0 | 0 |
Rule lists compile 1 148 024 parsed rules into 666130 after deduplication — 349 264 duplicates, 30 % of the input across 16 public lists. Deduplication is paid once at compile time and keeps the matcher smaller for the life of the process. It also shortens probe chains: a domain carried by two lists occupies one slot instead of two that hash to the same place, worth 46 % on lookups for shared domains.
The 20 k+ QPS figure is /tool profile on the live box under a synthetic hammer, with all four cores sharing evenly — reception is not the bottleneck, so SO_REUSEPORT stays a documented recipe rather than shipped code.
The refresh transient is the one figure above its budget. Recompiling the ruleset briefly holds the outgoing matcher, the freshly fetched list bodies and the new arena at the same time, peaking around 172 MiB before falling back to ~47~50 MiB. It is bounded and it does not ratchet — steady RSS returning after every refresh is the evidence — but it is real, and reducing it is the one Phase 2 task still open. Structural accounting: p2-12.
Memory is otherwise bounded, not merely small, and the instrument that proves it runs continuously: RSS − Σ(components) = residual is exported on every sample, so a leak shows as the residual growing while the named components stay flat — the growth you legitimately expect has already been subtracted out. Across two independent on-device windows of 61.3 h and 36.2 h (4 657 samples), the residual slopes are +0.082 and +0.027 MiB/h, with the sign disagreeing between the windows' final thirds: drift indistinguishable from noise.
RouterOS reports the container larger than the process is.
memory-currentsits around 85 MiB against a ~47 MiB resident set; the difference is cgroup page cache — reclaimable file-backed pages from the image layers and the cached rule lists — not consumption. The figure that matters is the process's own resident set.
RouterOS Usage Graphing (only 7h because I've deployed a new version):
[bobdenaut@bobdenaut-router] > /container/print detail
Flags: S - stopped, N - starting, R - running, T - stopping, E - downloading/extracting, D - deleting, F - download/extract failed
0 R ;;; fastadhunter
file=kingston/fastadhunter-rosready-0.2.16.tar remote-image="" check-certificate=yes name="fastadhunter-rosready-0.2.16" tag="docker.io/library/fastadhunter:0.2.16"
os="linux" arch="arm64" interface=veth1 env="" envlists=fah-env cmd="" entrypoint="" stop-signal=15-SIGTERM stop-time=10s root-dir=/kingston/fastadhunter/root
layer-dir="" mount="" tmpfs="" mountlists=fah-config,fah-data shm-size=64.0MiB dns="" hostname="" domain-name="" workdir="/home/nonroot" logging=yes
start-on-boot=yes auto-restart-interval=none cpu-list=cpu0,cpu1,cpu2,cpu3 memory-high=unlimited memory-current=87.3MiB cpu-usage=0.1 devices="" passed-devs=""
image-id="5e73563746f17231ae806cada9e7dbedd5da4fa39cd6cf1223133f9af4b8939c" layers=root hosts=""
[bobdenaut@bobdenaut-router] > /container/config/print
registry-url: https://registry-1.docker.io
username:
layer-dir:
tmpdir: /kingston/pull
memory-high: unlimited
memory-current: 87.7MiB
[bobdenaut@bobdenaut-router] > /system/resource/print
uptime: 7h7m39s
version: 7.21.5 (long-term)
build-time: 2026-07-03 10:23:40
factory-software: 7.8
free-memory: 804.3MiB
total-memory: 1024.0MiB
cpu: ARM64
cpu-count: 4
cpu-frequency: 350MHz
cpu-load: 1%
free-hdd-space: 959.9MiB
total-hdd-space: 1024.0MiB
write-sect-since-reboot: 796
write-sect-total: 401639
bad-blocks: 0%
architecture-name: arm64
board-name: RB5009UG+S+
platform: MikroTik
Operating modes
Fixed at container start via engine.mode:
| Mode | Filters |
|---|---|
dns |
Network-wide DNS filtering |
dns+http |
…plus URL-level filtering of unencrypted HTTP — deployed today |
dns+http+https |
…plus HTTPS interception, for managed environments |
A mode that does not name an engine means that engine's listener is never bound — not bound and idle.
Runtime model
Tokio Runtime
│
┌──────────────┼──────────────┐
│ │ │
Worker 1 Worker 2 Worker N
│ │ │
├── DNS ├── DNS ├── DNS
├── HTTP ├── HTTP ├── HTTP
└── Rules └── Rules └── Rules
Work is distributed across Tokio workers; the architecture avoids centralised processing. One task per datagram, one shared compiled ruleset behind an atomic swap, and no lock on the path that answers a query.
API
Everything the engine can do is reachable over REST + WebSocket. HTTPS by default, bearer-key auth. /health is the only unauthenticated route.
GET /health
GET /api/v1/telemetry whole engine state as JSON: counters,
latency stages, upstreams, cache, memory
GET /api/v1/stats aggregates, top domains/clients
GET /api/v1/clients per-client view; PUT to name one
PUT /api/v1/clients/{ip}/policy assign a policy to a client
GET /api/v1/policies policy CRUD, schedules, assignments
GET /api/v1/lists rule lists; POST /lists/refresh
GET /api/v1/rules/user user rules; PUT to replace
POST /api/v1/rules/test verdict for a domain, with the rule
GET /api/v1/cache cache stats; POST /cache/clean
GET /api/v1/history/{summary,perf,top}
GET /api/v1/config POST to patch, validated + written back
POST /api/v1/config/apikey/rotate rotate the bearer key
GET /api/v1/debug/memory per-component heap + residual
WS /api/v1/events live query stream

