A high-performance DNS proxy written in Rust that blocks malware, C2, and phishing domains at the DNS layer. No browser extensions, no per-app configuration — anything that resolves DNS gets filtered.
dnsink sits between your machine and the internet, checking every DNS query against live threat intelligence feeds. Blocked domains get an NXDOMAIN response in under 1 microsecond. Clean domains are forwarded upstream, optionally via encrypted DNS-over-HTTPS.
Existing DNS blockers (Pi-hole, AdGuard Home) focus on ad blocking. dnsink focuses on security — blocking known malware infrastructure, C2 servers, and phishing domains using real threat intelligence feeds that update hourly.
| Feature | dnsink | Pi-hole | AdGuard Home | crab-hole |
|---|---|---|---|---|
| Language | Rust | Shell/PHP | Go | Rust |
| Threat intel feeds | URLhaus, OpenPhish, PhishTank | Community ad lists | Community ad lists | Community ad lists |
| Wildcard blocking | Radix trie | Regex | Regex | Regex |
| Bloom filter pre-screening | Yes | No | No | No |
| DNS-over-HTTPS upstream | Yes | No (needs cloudflared) | Yes | Yes |
| Hot-reload without downtime | Yes (lock-free via ArcSwap) | Yes (restart-based) | Yes | Yes |
| Terminal dashboard (TUI) | Yes (ratatui) | Web UI | Web UI | No |
| Two-stage lookup (~490 ns) | Yes | No | No | No |
- Threat intelligence feeds — URLhaus (abuse.ch), OpenPhish, PhishTank (optional API key). Fetched at startup, hot-reloaded on a configurable interval.
- Two-stage blocking engine — Bloom filter pre-screens every query in ~184ns. Only probable matches hit the radix trie for confirmation. Definite misses (legitimate traffic) never touch the trie.
- Wildcard domain blocking — Block
malware.comand every subdomain automatically via label-reversed radix trie traversal. - DNS-over-HTTPS (DoH) — Encrypt upstream queries to Cloudflare, Google, or any RFC 8484 endpoint. Your ISP sees nothing.
- Hot-reload — Blocklists refresh without dropping in-flight queries. Lock-free reads via
ArcSwap, reference-counted old data viaArc. - Terminal dashboard (TUI) — Live query stream with color-coded blocked/allowed, queries-per-second sparkline, top blocked domains, and stats. Vim-style scrolling (j/k/g/G).
- Structured logging — JSON or text per-query logs with domain, action, latency, protocol, and source IP. File output configurable.
- Per-query metrics — Atomic counters for total/blocked/allowed queries, average latency, query type distribution, and top blocked domains.
- UDP + TCP — Full DNS protocol support with automatic truncation fallback.
- Per-feed toggles — Enable/disable URLhaus, OpenPhish, PhishTank independently in config.
# Clone and build
git clone https://github.com/kakarot-dev/dnsink.git
cd dnsink
cargo build --release
# Run with default config (listens on 127.0.0.1:5353)
cargo run --release
# Run with TUI dashboard
cargo run --release -- --tui
# Test it
dig @127.0.0.1 -p 5353 google.com # resolves normally
dig @127.0.0.1 -p 5353 malware.example.com # NXDOMAIN (blocked)Launch with --tui to get a live terminal dashboard:
cargo run --release -- --tui
The dashboard shows:
- Stats header — total queries, blocked count, allowed count, average latency, uptime
- Sparkline — queries per second over the last 60 seconds with current and peak rate
- Live query stream — color-coded table of every DNS query (red = blocked, green = allowed) with domain, query type, latency, and protocol
- Top blocked domains — the 5 most frequently blocked domains
Keyboard shortcuts:
| Key | Action |
|---|---|
q / Esc |
Quit |
j / Down |
Scroll down (older queries) |
k / Up |
Scroll up (newer queries) |
G / End |
Jump to latest (re-enable auto-scroll) |
g / Home |
Jump to oldest |
Headless mode (no --tui flag) runs the proxy with standard structured logging — no behavior change from previous versions.
Client query (UDP/TCP :5353)
|
v
+---------------+
| DnsProxy | receives raw DNS bytes, starts latency timer
+-------+-------+
|
v
+---------------+
| BloomFilter | stage 1: check domain + each parent label
| (100K items) | O(k) bit lookups — ~184 ns
| ~117 KB | definite miss -> skip trie, forward immediately
+-------+-------+
| maybe blocked
v
+---------------+
| DomainTrie | stage 2: authoritative answer
| (radix trie) | label-reversed traversal: "sub.evil.com" -> com.evil.sub
| | is_blocked at any ancestor = wildcard block
+-------+-------+
|
+----+----+
| |
blocked allowed
| |
v v
NXDOMAIN forward to upstream (UDP, TCP, or DoH)
response with automatic truncation fallback
| |
+----+----+
|
v
log + metrics
domain, action, latency, proto, qtype
|
v (if --tui)
TUI dashboard via mpsc channel
| Module | Responsibility |
|---|---|
proxy.rs |
Async UDP + TCP listeners, DoH upstream, two-stage block check, hot-reload via ArcSwap, query metrics, structured logging |
bloom.rs |
Packed bit-vector bloom filter, double hashing, no external crates |
trie.rs |
Radix trie with label-reversed domain storage, wildcard blocking via is_blocked flag |
feeds.rs |
ThreatFeed trait + URLhaus, OpenPhish, PhishTank implementations |
config.rs |
TOML config parsing: listen addr, upstream (UDP/DoH), feed toggles, logging, refresh interval |
tui.rs |
Ratatui terminal dashboard: stats, sparkline, live stream, top blocked domains |
Feeds are fetched at startup and hot-reloaded every refresh_secs (default: 3600s). Each feed can be toggled independently.
| Feed | Format | Auth | Domains (approx) |
|---|---|---|---|
| URLhaus | Plain text URLs | None | ~25,000 |
| OpenPhish | Plain text URLs | None | ~200 |
| PhishTank | JSON (url field) |
API key (optional) | ~50,000 |
Feed failures are logged and skipped — the proxy starts with whatever feeds succeed. A failed reload keeps the previous blocklist active.
Blocklists refresh every refresh_secs without dropping in-flight queries:
- Background tokio task sleeps for
refresh_secs - Fetches all enabled feeds + static blocklist
- Builds a new bloom filter and trie from scratch
- Atomically swaps via
ArcSwap— lock-free, wait-free reads - Old data stays alive via
Arcreference counting until in-flight queries finish
Built from scratch — no external crates. Packed Vec<u8> bit vector (8x smaller than Vec<bool>), double hashing:
h1 = upper 32 bits of DefaultHasher(item)
h2 = lower 32 bits | 1 (forced odd, coprime with m)
index(i) = (h1 + i * h2) % m
Optimal parameters for 100K items at 1% false positive rate: m = 958,506 bits (~117 KB), k = 7 hash functions.
Labels stored in reverse order so prefix matching becomes suffix matching:
insert("malware.com") -> root -> "com" -> "malware" [is_blocked=true]
contains("sub.malware.com"):
walk com -> malware -> is_blocked=true -> return true (wildcard hit)
never checks "sub" node — parent already blocked
Measured with Criterion on 100,000 domains, release build (WSL2, Linux 5.15):
| Operation | Time |
|---|---|
| Bloom lookup — hit | 87 ns |
| Bloom lookup — miss | 184 ns |
| Trie lookup — exact hit | 380 ns |
| Trie lookup — wildcard hit | 320 ns |
| Trie lookup — miss | 236 ns |
| Two-stage lookup — hit | 491 ns |
| Two-stage lookup — miss | 288 ns |
Two-stage miss (288 ns): The bloom filter eliminates clean domains in ~184 ns. The trie is never consulted. This is the common case — legitimate traffic takes the fastest path.
Wildcard faster than exact: is_blocked is checked at each trie node before descending. A blocked parent (malware.com) short-circuits immediately — no need to walk to the leaf.
Native Linux or bare-metal will show lower absolute times than WSL2.
[listen]
address = "127.0.0.1"
port = 5353
[upstream]
address = "8.8.8.8"
port = 53
timeout_ms = 5000
# protocol = "doh" # "udp" (default) or "doh"
# doh_url = "https://1.1.1.1/dns-query" # Cloudflare default if omitted
[blocklist]
path = "blocklist.txt" # one domain per line, # comments ok
[feeds]
urlhaus = true # abuse.ch URLhaus feed
openphish = true # OpenPhish community feed
# phishtank_api_key = "your-key" # optional, enables PhishTank
refresh_secs = 3600 # hot-reload interval (0 = disabled)
[logging]
format = "json" # "text" (default) or "json"
# file = "/var/log/dnsink/queries.log" # omit for stdout# Unit tests (30) + integration tests (5)
cargo test
# Benchmarks (HTML reports in target/criterion/)
cargo bench- Rust + tokio — async runtime
- hickory-proto — DNS wire format parsing
- reqwest + rustls — DoH upstream (RFC 8484)
- arc-swap — lock-free hot-reload
- ratatui + crossterm — terminal dashboard
- tracing — structured logging (JSON/text)
- criterion — benchmarks
- clap — CLI argument parsing
MIT