FastAdHunter — High-performance DNS/HTTP filtering on RouterOS containers

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 :white_check_mark: done v0.1.0-phase0
1 DNS + REST API + Docker :white_check_mark: done v0.2.0-phase1
1.5 Observability persistence :white_check_mark: done v0.3.0-phase1.5
2 HTTP engine + Policies :construction: shipped, one task open
3 HTTPS interception :white_large_square: not started
4 HTML filtering :white_large_square: not started

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 :grinning_face: ≥ 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-current sits 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 :heart_eyes:
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