Skypier Blackhole is a small DNS resolver that sits in front of your upstream servers and drops queries for domains you don't want resolved (ads, trackers, analytics, whatever you put on the list). Everything else is forwarded upstream unchanged. It's meant to run next to a Skypier VPN node, but there's nothing VPN-specific about it: point a machine or a whole network at it and it behaves like a stripped-down Pi-hole that ships as a single binary.
It's written in Rust on top of Tokio and Hickory DNS, with no unsafe code. The lookup path is plain in-memory data structures, so deciding whether a domain is blocked costs nothing compared to the network round trip you'd otherwise pay to reach the upstream resolver.
- How it works
- Installing
- Getting started
- Configuration
- Usage
- Development
- Troubleshooting
- FAQ
- Contributing
- License
- Acknowledgments
A query comes in over UDP/TCP on port 53. Before forwarding anything, the server checks the domain against the in-memory blocklist:
The bloom filter is a cheap first pass: if it says "no", the domain is
definitely not on the list and we skip straight to forwarding. If it says
"maybe", we confirm with an exact-match hashset, then fall back to the radix
trie for wildcard rules like *.malware.xyz. Blocked queries never touch
the network, so they come back about as fast as the kernel can hand the packet
back to you.
Blocked domains get a configurable response. By default that's a DNS REFUSED,
which is the fastest thing to return. You can also send NXDOMAIN or hand back
a fixed IP such as 0.0.0.0 if some client misbehaves on a refusal.
For the longer version, see doc/ARCHITECTURE.md.
The current stable release runs on Linux and macOS. Windows support is in
progress and will be released soon. The signal-based hot reload (SIGHUP) is
Unix only.
You'll need a recent Rust toolchain (1.70 or newer).
git clone https://github.com/SkyPierIO/skypier-blackhole.git
cd skypier-blackhole
cargo build --releaseThe binary lands at target/release/skypier-blackhole. On a Linux box with
systemd, a typical install looks like:
sudo cp target/release/skypier-blackhole /usr/bin/
sudo mkdir -p /etc/skypier /var/log/skypier
sudo cp config/blackhole.toml.example /etc/skypier/blackhole.toml
sudo cp systemd/skypier-blackhole.service /etc/systemd/system/
sudo systemctl daemon-reloadIf you'd rather not build it, grab the .deb from the releases page. It drops
the binary, a default config, and the systemd unit in the right places and
enables the service:
wget https://github.com/SkyPierIO/skypier-blackhole/releases/latest/download/skypier-blackhole_amd64.deb
sudo dpkg -i skypier-blackhole_amd64.deb
sudo systemctl status skypier-blackholeThe binary picks platform-appropriate paths so you don't have to pass --config
on every invocation:
| Platform | Config | Custom blocklist | Log |
|---|---|---|---|
| Linux | /etc/skypier/blackhole.toml |
/etc/skypier/custom-blocklist.txt |
/var/log/skypier/blackhole.log |
| macOS | /usr/local/etc/skypier/blackhole.toml |
/usr/local/etc/skypier/custom-blocklist.txt |
/usr/local/var/log/skypier/blackhole.log |
| Windows (planned) | C:\ProgramData\Skypier\blackhole.toml |
C:\ProgramData\Skypier\custom-blocklist.txt |
C:\ProgramData\Skypier\Logs\blackhole.log |
Pass --config /path/to/blackhole.toml to override.
Point your resolver at the server, start it, and confirm it blocks what you expect.
On a single machine, the quick version is editing /etc/resolv.conf:
# /etc/resolv.conf
nameserver 127.0.0.1If you're on systemd-resolved, do it the supported way instead so your change survives a reboot:
sudo mkdir -p /etc/systemd/resolved.conf.d/
cat <<'EOF' | sudo tee /etc/systemd/resolved.conf.d/skypier.conf
[Resolve]
DNS=127.0.0.1
Domains=~.
EOF
sudo systemctl restart systemd-resolvedStart it (either through systemd or directly), then run a query through it. Starting it in the foreground prints the banner and begins serving:
$ skypier-blackhole start
____ __ __ __ __
/ __ )/ /___ ______/ /__ / /_ ____ / /__
/ __ / / __ `/ ___/ //_// __ \/ __ \/ / _ \
___/ /_/ / / /_/ / /__/ ,< / / / / /_/ / / __/__
/________/_/\__,_/\___/_/|_|/_/ /_/\____/_/\______/
Skypier Blackhole v0.1.3
A fast, blocklist-driven DNS sinkhole
INFO Starting DNS server...
INFO Loaded 158432 total domains into blocklist
INFO Update scheduler startedA blocked domain comes back REFUSED, anything else resolves normally:
$ dig +short @127.0.0.1 malware.xyz
;; ->>HEADER<<- opcode: QUERY, status: REFUSED
$ dig +short @127.0.0.1 google.com
142.250.74.142You don't need a running server to ask whether a domain would be blocked. The
test subcommand loads the same lists from disk and reports the verdict:
$ skypier-blackhole test malware.xyz
Testing domain: malware.xyz
[x] Status: BLOCKED
[i] This domain will be blocked by the DNS server
-> DNS queries will receive: REFUSEDConfiguration is a single TOML file. The defaults that ship in config/blackhole.toml.example are sensible for a local-only resolver; the parts you'll actually touch are the listen address, the upstream servers, and which blocklists to pull.
[server]
listen_addr = "127.0.0.1" # "0.0.0.0" to serve a whole network
listen_port = 53
upstream_dns = ["1.1.1.1:53", "8.8.8.8:53"] # plain DNS and/or DoH (see below)
blocked_response = "refused" # "refused" | "nxdomain" | { ip = "0.0.0.0" }
[blocklist]
remote_lists = [
"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
]
local_lists = []
custom_list = "/etc/skypier/custom-blocklist.txt"
enable_wildcards = true
[logging]
log_blocked = true
log_path = "/var/log/skypier/blackhole.log"
log_level = "info" # trace | debug | info | warn | error
[updater]
enabled = true
schedule = "0 0 0 * * *" # cron (6-field, with seconds); default is daily at midnight
timezone = "EST"
update_on_start = true # also refresh remote lists once at startup (background)Full reference:
| Section | Key | Default | Notes |
|---|---|---|---|
server |
listen_addr |
127.0.0.1 |
Use 0.0.0.0 to serve other machines |
listen_port |
53 |
Ports below 1024 need privileges (see below) | |
upstream_dns |
["1.1.1.1:53"] |
Plain ip:port or DoH https://... (see below) |
|
blocked_response |
refused |
refused, nxdomain, or { ip = "..." } |
|
blocklist |
remote_lists |
[] |
URLs pulled by the updater |
local_lists |
[] |
Files loaded from disk at startup | |
custom_list |
/etc/skypier/custom-blocklist.txt |
Where add/remove write |
|
enable_wildcards |
true |
Enables *.domain.com rules |
|
logging |
log_blocked |
true |
Log each blocked query |
log_path |
/var/log/skypier/blackhole.log |
||
log_level |
info |
||
updater |
enabled |
true |
Background auto-update |
schedule |
0 0 0 * * * |
Cron expression (6-field: sec min hour dom month dow) | |
timezone |
EST |
Timezone the cron runs in | |
update_on_start |
true |
Refresh remote lists once at startup (background, non-fatal) |
Upstream entries can be DoH URLs instead of plain ip:port, so forwarded
queries leave the box encrypted (clients still talk plain DNS to the
blackhole). The format is https://<host>[:port][/dns-query][@bootstrap_ip[:port]]:
upstream_dns = [
"https://dns.quad9.net/dns-query@9.9.9.9:443", # hostname + bootstrap IP
"https://1.1.1.1/dns-query", # IP-literal host, no bootstrap
]A hostname needs the @bootstrap suffix because there's no working resolver
yet to look it up at startup; the hostname is still used for TLS certificate
verification. The endpoint path must be /dns-query (the port defaults
to 443).
There are three sources, all merged into one in-memory list at load time:
remote URLs that the updater downloads and caches, local files you point at,
and the custom list that the add/remove commands manage for you. The format
is the usual one domain per line, # starts a comment, and blank lines are
ignored. It reads StevenBlack-style hosts files and plain domain lists the
same way.
Wildcards block every subdomain but leave the apex alone, which is usually what you want:
| Rule | Blocks | Leaves alone |
|---|---|---|
*.example.com |
ads.example.com, a.b.example.com |
example.com |
*.ads.example.com |
x.ads.example.com |
ads.example.com, example.com |
exact.com |
exact.com |
sub.exact.com |
A custom list looks like this:
# /etc/skypier/custom-blocklist.txt
ads.example.com
tracker.example.com
*.malware.xyz
*.googlesyndication.com
If [updater] enabled = true, a cron task runs inside the server, downloads
everything in remote_lists on schedule, writes it to a cache file next to
your custom list, and hot-reloads without dropping queries. The schedule is a
six-field cron expression (the leading field is seconds, as required by
tokio-cron-scheduler) interpreted in the configured timezone:
0 0 0 * * * daily at midnight
0 0 */6 * * * every six hours
0 0 3 * * 0 Sundays at 03:00
0 */30 * * * * every thirty minutes
With update_on_start = true (the default), the server also kicks off a one-off
refresh as soon as it starts, so a freshly installed node doesn't wait for the
first scheduled tick to pull its remote lists. This runs in the background after
the DNS server is already serving, and a failed download is non-fatal β the
daemon falls back to the cached list and logs a warning.
You can always force a refresh by hand with skypier-blackhole update, and you
can turn the scheduler off entirely with enabled = false.
The CLI is the same binary you run as the server. The subcommands that talk to
a running server (stop, reload, and the implicit reload after add,
remove, and update) find it by PID and send it a signal, so they only do
anything on Unix while the server is up.
skypier-blackhole start # run the DNS server
skypier-blackhole stop # graceful shutdown (SIGTERM)
skypier-blackhole reload # hot-reload the lists (SIGHUP)
skypier-blackhole status # process state + blocklist stats
skypier-blackhole list # per-source domain counts
skypier-blackhole update # pull remote lists now
skypier-blackhole test <domain> # would this domain be blocked?
skypier-blackhole add <domain> # append to the custom list, reload
skypier-blackhole remove <domain> # drop from the custom list, reload
skypier-blackhole tui # run the server with a live dashboardadd and remove edit the custom list and, if the server is up, reload it on
the spot so the change is live immediately:
$ skypier-blackhole add ads.example.com
Adding domain: ads.example.com
[ok] Domain added to: /etc/skypier/custom-blocklist.txt
[*] Reloading server...
[ok] Server reloaded, domain is now blockedstatus tells you whether the server is running and what it's serving:
$ skypier-blackhole status
Skypier Blackhole Status
==================================================
[+] Server Status: RUNNING
[*] Process ID: 48213
[*] Blocklist Statistics:
- Total domains blocked: 158432
- Custom list: /etc/skypier/custom-blocklist.txt
[*] Configuration:
- Listen: 127.0.0.1:53
- Upstream DNS: 1.1.1.1:53
==================================================skypier-blackhole tui runs the DNS server with a full-screen terminal
dashboard instead of plain log output. It shows a live activity log (blocked
queries highlighted), the configured upstream resolvers, in-memory session
stats (query counts, block rate, top blocked domains since startup; nothing is
persisted), and a blocklist summary with per-source counts and the last/next
remote update.
Key bindings:
| Key | Action |
|---|---|
a |
add a domain to the custom list (live) |
d |
remove a domain from the custom list (live) |
u |
download remote blocklists now |
r |
reload all lists from disk |
q / Esc / Ctrl+C |
quit |
The shipped unit handles the privileged-port capability and the signals for
you, so on a server you mostly use systemctl:
sudo systemctl enable --now skypier-blackhole # start on boot and now
sudo systemctl reload skypier-blackhole # SIGHUP, hot-reload
sudo systemctl stop skypier-blackhole # SIGTERM, graceful
sudo journalctl -u skypier-blackhole -f # follow the logsOn Unix the server responds to three signals. SIGHUP rebuilds the blocklist
from disk in place; in-flight queries keep flowing and there's no window where
the server is down. SIGTERM and SIGINT (Ctrl-C) stop accepting new queries,
finish the ones already in progress, and exit cleanly.
kill -HUP $(pgrep -f 'skypier-blackhole.*start') # reload
kill -TERM $(pgrep -f 'skypier-blackhole.*start') # shut downskypier-blackhole reload and skypier-blackhole stop are thin wrappers around
those two. Implementation notes and test results live in
wip/SIGNAL_HANDLING_COMPLETE.md.
To use one instance for a LAN or a VPN, set listen_addr = "0.0.0.0" and point
clients at the host's address. For VPN nodes you'd push it as the DNS server:
# OpenVPN server.conf
push "dhcp-option DNS 10.8.0.1"# WireGuard wg0.conf
[Interface]
DNS = 10.8.0.1cargo build # debug build
cargo build --release # optimized build (LTO, stripped)
cargo test # run the test suite
cargo clippy # lint
cargo fmt # format
RUST_LOG=debug cargo run -- start --config config/blackhole.toml.exampleSource layout:
src/
main.rs entry point
lib.rs library exports
cli.rs argument parsing and subcommands
config.rs TOML config and platform-aware defaults
dns.rs the DNS server itself
blocklist.rs bloom filter + hashset + radix trie
downloader.rs remote blocklist fetching
scheduler.rs cron-driven auto-update
logger.rs tracing setup
If queries aren't being answered, first confirm the server is up and actually listening on 53:
sudo systemctl status skypier-blackhole
sudo ss -ulpn | grep :53
dig @127.0.0.1 google.comA permission denied on startup almost always means port 53 without the
privilege to bind it. Under systemd the unit grants CAP_NET_BIND_SERVICE, so
this only bites when you run the binary by hand. Either run it as root for a
quick test or set listen_port = 5353 and query that port instead.
If the lists aren't refreshing, check that [updater] is enabled, that the
URLs are reachable, and force an update to see the error directly:
curl -I https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
skypier-blackhole updateHow is this different from Pi-hole? Same idea, much smaller surface. It's a single Rust binary with no PHP, no web UI, and no external cron, and it runs on Linux and macOS today (Windows support is in progress and will be released soon). The tradeoff is that you configure it through a TOML file and the CLI rather than a dashboard.
Can I use it as a Pi-hole replacement at home? Yes. Set
listen_addr = "0.0.0.0" and point your devices (or your router's DHCP DNS
setting) at the host.
Does it do DNSSEC? Not yet.
Can I whitelist domains? Not yet, that's planned. For now, remove a domain from your lists rather than overriding it.
Multiple upstreams? Yes, list them in upstream_dns. A random one is
picked for each query, so no single resolver sees every lookup.
What list formats work? One domain per line with # comments, including
StevenBlack hosts files. Wildcards use the *.domain.com syntax.
Pull requests welcome. The short version: fork, branch, make the change, run
cargo test, cargo clippy, and cargo fmt, then open a PR. See
CONTRIBUTING.md for the details.
Licensed under the MIT License (LICENSE-MIT).
Built on Hickory DNS and Tokio. Blocklist format and inspiration from Pi-hole and StevenBlack/hosts.
