A DNS filtering server written in Rust that blocks malicious and ad-serving domains using crowd-sourced blocklists. Queries are forwarded to upstream resolvers over DNS-over-TLS (DoT) for encrypted resolution. Includes a DoT listener for encrypted client connections (e.g. from dns-proxy).
- Domain blocking — Downloads and merges blocklists from GitHub (StevenBlack, HaGezi). Lookups use a Bloom filter + HashSet for fast, memory-efficient matching with exact and wildcard support.
- DNS-over-TLS — All upstream queries are encrypted (Google DNS, Cloudflare). Incoming DoT listener on port 853 for encrypted client connections.
- Hot-reload — Blocklist refreshes daily and swaps in atomically (lock-free via ArcSwap). No downtime, no query drops.
- Local query bypass — Queries from localhost skip the blocklist and use the original VPC DNS resolver, so EC2 services like SSM and metadata work normally.
- Query logging — Stores 7 days of queries in SQLite (WAL mode, batched async writes). Records domain, query type, source IP, action (allowed/blocked/local), and resolved IP.
- Block responses — Configurable: NXDOMAIN (domain doesn't exist) or sinkhole (returns 0.0.0.0).
Client (UDP :53) ──────────────────┐
▼
dns-proxy (macOS) ── DoT :853 ──> dns (EC2)
│
┌─────────┴─────────┐
▼ ▼
Blocklist check Local bypass
(Bloom + HashSet) (VPC DNS)
│ │
blocked? │
┌──┴──┐ │
▼ ▼ ▼
NXDOMAIN Sinkhole VPC resolver
(plain UDP)
allowed?
│
▼
Upstream DoT
(Google DNS, Cloudflare)
│
▼
SQLite log
- AWS EC2 instance running Ubuntu
- Rust toolchain (
rustup,cargo)
cargo build --releaseThe install script handles everything: stops systemd-resolved, captures the original VPC DNS, creates a system user, installs the binary, generates a self-signed TLS cert, and starts the service.
# Build first
cargo build --release
# Generate TLS cert for DoT listener
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem \
-days 365 -nodes -subj "/CN=dns-filter"
# Install (requires root)
sudo ./install.sh- Stops and disables
systemd-resolvedto free port 53 - Captures the original VPC DNS resolver (typically
x.x.x.2) for local query bypass - Sets
/etc/resolv.confto127.0.0.1and marks it immutable - Creates a
dns-filtersystem user - Copies the binary, config, and TLS certs to
/opt/dns-filter/ - Grants
CAP_NET_BIND_SERVICEso the binary can bind ports 53/853 without root - Updates config.toml for production (port 53, port 853, VPC DNS, db path)
- Creates and starts a systemd service with security hardening
The service runs as the unprivileged dns-filter user with:
ProtectSystem=strict— read-only filesystem except/opt/dns-filterProtectHome=true— no access to home directoriesNoNewPrivileges=true- Auto-restart on failure (5s delay)
# Service management
sudo systemctl status dns-filter
sudo systemctl restart dns-filter
sudo journalctl -u dns-filter -fEdit /opt/dns-filter/config.toml (or config.toml for local development):
[server]
listen_udp = "0.0.0.0:53"
debug = true # log every query to stdout
[server.dot]
listen = "0.0.0.0:853"
cert_pem = "cert.pem"
key_pem = "key.pem"
[upstream]
timeout_ms = 3000
local_resolver = "172.31.0.2:53" # auto-set by install.sh
[[upstream.resolvers]]
addr = "8.8.8.8:853"
tls_name = "dns.google"
[[upstream.resolvers]]
addr = "1.1.1.1:853"
tls_name = "cloudflare-dns.com"
[logging]
enabled = true
db_path = "/opt/dns-filter/query_log.db"
retention_days = 7
[blocklist]
refresh_interval_secs = 86400
block_response = "nxdomain" # or "sinkhole"
sinkhole_ipv4 = "0.0.0.0"
sinkhole_ipv6 = "::"
[[blocklist.sources]]
url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
format = "hosts"
enabled = true
[[blocklist.sources]]
url = "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/pro.txt"
format = "domains"
enabled = true
[[blocklist.sources]]
url = "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/pro.txt"
format = "domains"
enabled = true# Should resolve normally
dig @127.0.0.1 google.com
# Should return NXDOMAIN (blocked)
dig @127.0.0.1 ads.facebook.com
# Test DoT listener
kdig @127.0.0.1#853 +tls google.comThe SQLite database at /opt/dns-filter/query_log.db stores all queries. Use the dns-web dashboard to view and filter the logs, or query directly:
sqlite3 /opt/dns-filter/query_log.db "SELECT * FROM query_log ORDER BY ts DESC LIMIT 10;"# Uses dev ports 5353/8853 from config.toml
sudo cargo run -- config.toml
dig @127.0.0.1 -p 5353 google.comsudo systemctl stop dns-filter
sudo systemctl disable dns-filter
sudo rm /etc/systemd/system/dns-filter.service
sudo systemctl daemon-reload
sudo rm -rf /opt/dns-filter
sudo userdel dns-filter
# Restore systemd-resolved
sudo chattr -i /etc/resolv.conf
sudo systemctl enable systemd-resolved
sudo systemctl start systemd-resolved
sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf