Skip to content

Repository files navigation

s-hole

A lightweight, self-contained DNS sinkhole for network-wide ad and tracker blocking. Deploy it on any always-on machine, point your router's DHCP DNS field at it, and every device on the network is protected — no per-device configuration required.

s-hole is intentionally small: a single binary, a single YAML config file, no runtime dependencies. The full codebase fits comfortably in an afternoon's reading.

Contents

For maintainer-facing material, see docs/DESIGN.md (design rationale), docs/CL.md (change-list index → docs/cls/), docs/BUGS.md (Buganizer-style bug tracker), docs/CHANGELOG.md (release notes), docs/ROADMAP.md (planned work and non-goals), and CONTRIBUTING.md.


Features

  • Network-wide blocking — blocks ads and trackers at the DNS layer before any connection is established
  • Community blocklists — downloads and auto-refreshes hosts-file or plain-domain lists from any URL
  • DNS response cache — serves repeat queries from memory; typical cache hit rates of 40–70% reduce upstream load and latency
  • Dual query log — plain-text file for grep/tail and a SQLite database for historical queries
  • Admin web UI — live stats, top blocked domains, recent query log, whitelist management; auto-refreshes every 3 seconds
  • REST API — all UI data available as JSON; suitable for scripting and future integrations
  • Configurable sinkhole mode — return 0.0.0.0 (default, silent failure) or NXDOMAIN
  • Cross-platform — single binary for Windows, Linux x86-64, Linux arm64 (Pi 4/5), Linux armv7 (Pi 2/3)
  • Windows Service — installs as an auto-start system service with one command
  • Linux systemd — ships a hardened unit file with CAP_NET_BIND_SERVICE (no root required at runtime)
  • Docker — multi-stage image, ~25 MB

Quick Start

Prerequisites

  • Go 1.25 or later (for building from source)
  • Port 53 available (requires Administrator on Windows, root or CAP_NET_BIND_SERVICE on Linux)

Install via the Go toolchain

If your $GOBIN is on PATH, the latest commit can be fetched with:

go install github.com/lcsabi/s-hole/cmd/s-hole@latest

Run interactively

# Build from a local clone
go build -o s-hole ./cmd/s-hole

# Run (requires elevated privileges for port 53)
sudo ./s-hole -config config.yaml          # Linux / macOS
.\s-hole.exe -config config.yaml           # Windows (Administrator)

On first run, blocklists are downloaded (~80 000 domains with the default lists; the exact count shifts as the upstream lists evolve) and cached to disk. Subsequent starts skip the download if the cache is less than 24 hours old.

Point your router at it

In your router's DHCP settings, set the DNS Server field to the IP address of the machine running s-hole. All devices on the network will pick up the new DNS server on their next DHCP renewal (or immediately after reconnecting).

Keep a fallback upstream DNS as the secondary DNS entry (e.g. 1.1.1.1) in case s-hole is unavailable.

IPv6 networks: on a dual-stack LAN, routers typically advertise a DNS server over IPv6 as well (via RA/RDNSS or DHCPv6) — and many clients prefer it. If that advertisement still points at the router or your ISP, dual-stack devices will quietly bypass s-hole for most queries and the ads come back. Either disable the router's IPv6 DNS advertisement, or give the s-hole machine a stable IPv6 address and advertise that instead (s-hole listens on IPv6 by default via listen: ":53").

Verify it works

With nslookup (preinstalled on Windows and macOS):

nslookup doubleclick.net <s-hole-ip>
# expected: Address: 0.0.0.0

nslookup google.com <s-hole-ip>
# expected: a real IP address

Or with dig (apt install dnsutils / dnf install bind-utils):

dig @<s-hole-ip> doubleclick.net +short   # expected: 0.0.0.0
dig @<s-hole-ip> google.com +short        # expected: a real IP address

These commands address s-hole explicitly, so they work even before the router change above. Network-wide blocking — devices being filtered without naming the server — only begins once DHCP hands out s-hole's address and clients renew their leases.

If a query times out, check s-hole's log (stdout, or journalctl -u s-hole -f under systemd): every query that reaches the process produces one ALLOW/BLOCK line. A missing line means the query never arrived — look at the network path (firewall, wrong IP, client tool) rather than at s-hole.


Configuration

All configuration lives in config.yaml. Every field has a safe default; an empty file is valid.

Field Default Description
listen :53 Address and port for DNS queries (UDP + TCP). :53 binds all interfaces, IPv4 + IPv6; use 0.0.0.0:53 for IPv4 only
upstreams [1.1.1.1:53, 8.8.8.8:53] Upstream resolvers, tried in order
blocklists StevenBlack + AdAway List of URLs to download (hosts-file or plain-domain format)
whitelist [] Domains that are never blocked, regardless of blocklist membership
refresh_interval 24h How often to re-download blocklists
block_mode zero Sinkhole reply: zero returns 0.0.0.0/::, nxdomain returns NXDOMAIN
block_ttl 300 TTL (seconds) advertised on blocked replies; 0 tells clients not to cache them
log_file stdout Path to the plain-text query log
log_queries all Which queries to write to logs: all, blocked, or none
query_db (off) Path to the SQLite query log database; set a path to enable, empty disables it
db_flush_interval 30s How often buffered queries are committed to SQLite
cache_size 2000 Maximum DNS responses held in the in-memory cache (0 to disable)
stats_interval 5m How often stats are printed to stdout
api_listen 127.0.0.1:8080 Address for the admin web UI and REST API. Set to 0.0.0.0:8080 to expose to the LAN.
cache_dir . Directory for cached blocklist files
query_db_retention_days 0 (forever) Delete query-log rows older than this many days. 0 disables the prune.
enable_pprof false Expose /debug/pprof/* on the admin server. Localhost-only deployment recommended.
local_ptr true Answer PTR queries for RFC 6303 private ranges (10/8, 172.16/12, 192.168/16, fc00::/7, fe80::/10) locally with NXDOMAIN. Set to false if you run a private reverse DNS zone on your LAN.

Minimal config example

upstreams:
  - "9.9.9.9:53"     # Quad9 — privacy-focused, malware-blocking
whitelist:
  - "api.example.com"
log_queries: blocked

Environment variable overrides

For container deployments where editing config.yaml requires a re-bind-mount, every commonly-tuned field can be overridden by an S_HOLE_* environment variable. The override is applied after the YAML is parsed:

Variable Equivalent YAML field
S_HOLE_LISTEN listen
S_HOLE_API_LISTEN api_listen
S_HOLE_LOG_FILE log_file
S_HOLE_LOG_QUERIES log_queries
S_HOLE_QUERY_DB query_db
S_HOLE_CACHE_DIR cache_dir
S_HOLE_BLOCK_MODE block_mode
S_HOLE_REFRESH_INTERVAL refresh_interval
S_HOLE_STATS_INTERVAL stats_interval
S_HOLE_DB_FLUSH_INTERVAL db_flush_interval
S_HOLE_CACHE_SIZE cache_size (integer)
S_HOLE_BLOCK_TTL block_ttl (integer)
S_HOLE_RETENTION_DAYS query_db_retention_days (integer)
S_HOLE_ENABLE_PPROF enable_pprof (1/true/yes enable)
S_HOLE_LOCAL_PTR local_ptr (1/true/yes keep on; 0/false/no opt out)
S_HOLE_LOG_FORMAT text (default) or json — controls slog handler
S_HOLE_ASCII_BANNER set to 1 to use ASCII box-drawing on the startup banner

Recommended config for Raspberry Pi

db_flush_interval: "60s"   # reduce SD card write frequency
cache_size: 5000            # more cache = fewer upstream queries
log_queries: blocked        # skip logging allowed queries to save writes

REST API

The admin web UI is served at http://127.0.0.1:8080 by default — localhost only, so a fresh install is not reachable from the LAN. Set api_listen: "0.0.0.0:8080" in config.yaml (or S_HOLE_API_LISTEN=...) to expose it. All data is also available as JSON.

Method Endpoint Description
GET /api/stats Live stats: uptime, query totals, block rate, cache hit rate, blocklist size, top domains/clients
GET /api/queries?limit=N Last N queries from SQLite, newest first (default: 50, max: 1000)
GET /api/whitelist List all runtime-whitelisted domains
POST /api/whitelist Add a domain — body: {"domain": "example.com"}
DELETE /api/whitelist?domain=… Remove a domain from the runtime whitelist
POST /api/reload Trigger an immediate blocklist refresh — de-duplicated via single-flight mutex (returns "reload already in progress" if one is running)
GET /healthz Liveness probe — always 200 OK while the HTTP server is responsive
GET /readyz Readiness probe — 200 OK once the blocklist has loaded at least one entry; 503 otherwise
GET /metrics Prometheus text exposition: shole_queries_total, shole_blocked_total, shole_local_ptr_total, shole_cache_hits_total, shole_cache_misses_total, shole_cache_size, shole_blocklist_size, shole_whitelist_size, shole_query_log_dropped_total
GET /debug/pprof/* Standard Go pprof endpoints — registered only when enable_pprof: true is set in config (or S_HOLE_ENABLE_PPROF=1). Pair with api_listen: "127.0.0.1:8080".

Runtime whitelist changes take effect immediately but do not persist across restarts. To make a whitelist entry permanent, add it to config.yaml.


Deployment

The Quick Start runs s-hole as a foreground process — it lives exactly as long as your terminal session and dies with a reboot, a crash, or a logout. That's fine for evaluation, but once your router points the LAN at s-hole, every device's internet depends on it. Deployment registers the binary as a service: the operating system (systemd, the Windows SCM, or Docker's restart policy) starts it at boot, restarts it if it crashes, and runs it detached from any user session.

If you want the admin dashboard reachable from other devices, set api_listen: "0.0.0.0:8080" in config.yaml before installing — the default binds localhost only, and the UI is unauthenticated, so LAN exposure is a deliberate opt-in.

Raspberry Pi / Linux (systemd)

# Cross-compile on your development machine:
make pi          # arm64 — Pi 4, Pi 5
make pi32        # armv7 — Pi 2, Pi 3

# Copy binary, config, and install script to the Pi:
scp s-hole-linux-arm64 pi@raspberrypi.local:~/
scp config.yaml pi@raspberrypi.local:~/
scp deploy/install-linux.sh pi@raspberrypi.local:~/

# On the Pi — run the installer as root:
sudo bash install-linux.sh ./s-hole-linux-arm64 ./config.yaml

The installer creates a s-hole system user, places the binary at /usr/local/bin/s-hole, installs config to /etc/s-hole/config.yaml, and enables the service to start on boot.

After installation:

sudo systemctl status s-hole     # check running state
sudo systemctl stop s-hole       # stop the service
sudo systemctl start s-hole      # start the service
sudo systemctl restart s-hole    # restart (e.g. after editing config)
sudo systemctl disable s-hole    # don't start on boot
sudo systemctl enable s-hole     # re-enable autostart
journalctl -u s-hole -f          # follow logs live

To trigger an immediate blocklist refresh without restarting (Linux/macOS):

sudo systemctl kill -s HUP s-hole       # via systemd
sudo kill -HUP "$(pidof s-hole)"        # or directly

SIGHUP is honored on every non-Windows platform; it runs the same single-flight refresh as POST /api/reload.

The systemd unit runs with CAP_NET_BIND_SERVICE so it can bind port 53 without running as root. ProtectSystem=strict and NoNewPrivileges are set for defence in depth.

Docker

1. Create a data directory and place your config in it:

mkdir -p data
cp config.yaml data/

The container runs as /app as its working directory and reads config from /app/config.yaml. Mounting ./data there keeps all persistent files — the SQLite database, blocklist cache, and config — on the host so they survive container restarts and image upgrades.

2. Build the image:

docker build -t s-hole .

3. Run:

docker run -d \
  --name s-hole \
  --restart unless-stopped \
  --cap-add=NET_BIND_SERVICE \
  -p 53:53/udp -p 53:53/tcp \
  -p 8080:8080 \
  -v "$(pwd)/data:/app" \
  s-hole

After the first run ./data will look like this:

data/
├── config.yaml             ← your config (you created this)
├── queries.db              ← SQLite query log
└── blocklist_*.txt         ← cached blocklist downloads

To update config, edit ./data/config.yaml and restart the container:

docker restart s-hole

On Windows (PowerShell), use backtick for line continuation and ${PWD} for the current directory:

docker run -d `
  --name s-hole `
  --restart unless-stopped `
  --cap-add=NET_BIND_SERVICE `
  -p 53:53/udp -p 53:53/tcp `
  -p 8080:8080 `
  -v "${PWD}\data:/app" `
  s-hole

Note (Linux host): port 53 is often already occupied by systemd-resolved's stub listener. If docker run fails with "address already in use", turn off just the stub — do not disable the whole service (on distros where /etc/resolv.conf points at the stub address, that kills the host's own DNS resolution):

sudo mkdir -p /etc/systemd/resolved.conf.d
printf '[Resolve]\nDNSStubListener=no\n' | sudo tee /etc/systemd/resolved.conf.d/no-stub.conf
sudo systemctl restart systemd-resolved

systemd-resolved keeps resolving for the host; only the 127.0.0.53:53 listener is released. Then re-run the docker run command.

Windows (system service)

Run once as Administrator to register s-hole as an auto-start Windows Service:

# Install (uses the config path you specify; must be absolute)
.\s-hole.exe -service install -config C:\s-hole\config.yaml

# Start / stop
.\s-hole.exe -service start
.\s-hole.exe -service stop

# Remove
.\s-hole.exe -service uninstall

The service can also be managed through the standard Windows Services panel (services.msc) or sc.exe.


Building from Source

# Current platform
make

# Cross-compilation targets
make pi          # Linux arm64 (Raspberry Pi 4 / 5)
make pi32        # Linux armv7 (Raspberry Pi 2 / 3)
make linux       # Linux amd64

# Clean
make clean

All targets produce a statically linked binary with debug info stripped (-ldflags="-s -w"). No CGO is required — modernc.org/sqlite is a pure Go SQLite port.

On Windows without make, use PowerShell:

$env:GOOS="linux"; $env:GOARCH="arm64"
go build -ldflags="-s -w" -o s-hole-linux-arm64 ./cmd/s-hole
$env:GOOS=""; $env:GOARCH=""

Development

The Makefile is the canonical entry point for every routine task. Run make help for the full list. The most useful targets:

make check       # gofmt + go vet + golangci-lint + go test
make test        # plain test run
make test-race   # tests under the race detector (CGO toolchain required)
make bench       # one iteration of each benchmark
make lint        # golangci-lint
make vuln        # govulncheck — scan deps + code for known CVEs
make fmt         # gofmt -s -w
make install     # go install into $GOBIN
make version     # print the version that the next build would embed

Coverage by package (after go test -cover ./...):

Package Coverage
internal/stats 100 %
internal/config 100 %
internal/version 100 %
internal/cache 94.8 %
internal/api 91.2 %
internal/blocklist 89.9 %
internal/dnsserver 88.4 %
internal/querylog 85.6 %
cmd/s-hole 31.7 %
module-wide 77.5 %

The uncovered region is the main() bootstrap and the Windows-only SCM glue — both exercised by manual smoke tests, not unit tests.

The binary reports its build identity at any time:

$ s-hole -version
s-hole v1.0.0
  commit:  ab12cd3
  built:   2026-06-24T12:00:00Z
  go:      go1.25.0
  os/arch: linux/amd64

CI runs lint + go mod verify + race-enabled tests + govulncheck + cross-compile for linux/{amd64,arm64,armv7} and windows/amd64 on every push and PR — see .github/workflows/ci.yml. The race-enabled run also exercises go.uber.org/goleak, which fails the goroutine-heavy packages (cache, querylog, dnsserver) if any goroutine outlives its tests. Dependabot keeps Go modules, GitHub Actions, and the Docker base image up to date.

Fuzz tests live alongside the unit tests for blocklist.ValidDomain, blocklist.parseHostsFormat, and blocklist.cacheFilename. Run them ad-hoc with go test -fuzz=FuzzValidDomain -fuzztime=30s ./internal/blocklist/.

A full end-to-end integration test (internal/dnsserver/integration_test.go) wires the store + cache + querylog + handler + DNS server + a mock UDP upstream together and exercises three real DNS queries through it — catching wiring bugs that unit tests miss.


Architecture

                   Client devices (DNS via DHCP)
                                │
                                │ UDP/TCP :53
                                ▼
     ┌──────────────────────────────────────────────────────┐
     │                   s-hole process                     │
     │                                                      │
     │   ┌──────────────────────────────────────────────┐   │
     │   │  DNS Handler  (per query)                    │   │
     │   │    1. private PTR → local NXDOMAIN (RFC6303) │   │
     │   │    2. blocklist  → sinkhole reply            │   │
     │   │    3. cache hit  → cached reply              │   │
     │   │    4. cache miss → upstream forward + cache  │   │
     │   └──────────────────────────────────────────────┘   │
     │                                                      │
     │   ┌───────────┐   ┌──────────┐   ┌───────────┐       │
     │   │ Blocklist │   │  Stats   │   │ Querylog  │       │
     │   │   Store   │   │ Counter  │   │ file + DB │       │
     │   └───────────┘   └──────────┘   └───────────┘       │
     │                                                      │
     │   ┌──────────────────────────────────────────────┐   │
     │   │  Admin HTTP server (default localhost:8080)  │   │
     │   │   /api/* + web UI                            │   │
     │   │   /healthz   /readyz   /metrics              │   │
     │   │   /debug/pprof/* (opt-in)                    │   │
     │   └──────────────────────────────────────────────┘   │
     │                                                      │
     │   Signals: SIGINT/SIGTERM → shutdown                 │
     │            SIGHUP (Unix)  → blocklist refresh        │
     │   Timers : periodic refresh; periodic stats print    │
     └──────────────────────────────────────────────────────┘
                                │  on cache miss
                                │  ctx-bounded; 3 s per upstream
                                │  + 30 s health cooldown
                                ▼
                    Upstream DNS (1.1.1.1, 8.8.8.8)

Repository layout

.
├── cmd/s-hole/        application entry point (main package)
├── internal/          implementation packages (not importable externally)
├── deploy/            systemd unit + Linux install script
├── docs/              DESIGN, CHANGELOG, BUGS, ROADMAP, and CL.md (index)
│   └── cls/           one file per CL (CL-01.md … CL-NN.md)
├── .github/           CI workflows, dependabot, CODEOWNERS, PR & issue templates
├── .golangci.yml      lint config
├── CLAUDE.md          AI-assistant guidance (commands, architecture, conventions)
├── config.yaml        default configuration
├── Dockerfile         multi-stage container build
├── Makefile           build + lint + test + install targets
├── CONTRIBUTING.md    development workflow + PR conventions
├── LICENSE            MIT
├── README.md          you are here
└── SECURITY.md        security disclosure policy

Package layout

All implementation packages live under internal/ so they cannot be imported by external modules.

Package Responsibility
internal/blocklist Download, parse, cache, and serve the domain block set
internal/cache TTL-based in-memory DNS response cache
internal/dnsserver UDP/TCP server, per-query handler, upstream forwarding with health tracking
internal/querylog Async file and SQLite query loggers
internal/stats Atomic counters; top-N domain/client tracking
internal/api HTTP handlers and embedded web UI
internal/config YAML loading with defaults and validation
internal/service Windows Service integration (build-tagged)

Dependencies

The "afternoon's reading" claim extends to the dependency graph: four direct modules linked into the binary, chosen where hand-rolling would be a source of subtle bugs and skipped everywhere else. (A fifth direct module, go.uber.org/goleak, is test-only — it runs the suite under a goroutine-leak check and is never compiled into the shipped binary.)

Module Why it's a dependency
github.com/miekg/dns Complete RFC-compliant DNS codec, server, and client — rolling our own would be a correctness minefield
modernc.org/sqlite Pure-Go SQLite for the query log; no CGO, so cross-compilation stays a one-liner
gopkg.in/yaml.v3 Parses config.yaml
golang.org/x/sys Windows Service Control Manager integration

The indirect modules in go.mod are almost all pulled in by the pure-Go SQLite port; none are used directly. Everything else is deliberately hand-rolled or omitted — the Prometheus exposition is written by hand rather than importing client_golang, the web UI is framework-free embedded HTML/CSS/JS, and the systemd integration is a static unit file rather than a service library. The reasoning behind each choice (and the alternatives rejected) is in docs/DESIGN.md. New dependencies need discussion first — see CONTRIBUTING.md.


Security Notes

  • s-hole is designed for LAN deployment only. Do not expose port 53 to the public internet; there is no rate limiting or source validation.
  • The SQLite query log and flat log file contain full browsing history for all devices. Treat them as sensitive data. Use log_queries: none if you do not need query history.
  • The admin UI has no authentication. Set api_listen: "127.0.0.1:8080" to restrict it to localhost, or use a firewall rule to limit access. The HTTP server enforces read/write/idle timeouts and a 64 KiB request body limit to defend against slowloris-style attacks from LAN peers, but these are no substitute for proper access control on a multi-user network.
  • Blocklist URLs are operator-controlled. Use HTTPS URLs from sources you trust.

License

MIT — see the LICENSE file for the full text.

About

A lightweight, network-wide, single-binary DNS sinkhole. A Pi-hole alternative written in Go, with an embedded web dashboard.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages