Umberrelay is a forwarding DNS server that logs every query, identifies which network actor made it, and classifies domains against community-maintained tracking lists. It gives you an attribution-focused picture of where your network traffic is going — and how much of it is talking to trackers.
- Forwarding DNS server — drop-in replacement for your router's DNS, forwards to upstream resolvers (Cloudflare, Google, etc.)
- Attribution with source-IP fallback — maps queries to devices via ARP table polling, DHCP snooping, mDNS, and SSDP discovery, and surfaces source IP when MAC is unavailable
- Domain classification — matches queries against configurable blocklists (Steven Black, EasyPrivacy, Disconnect.me) with automatic refresh
- OUI vendor lookup — identifies device manufacturers from MAC address prefixes
- Web UI — Home, Devices, and Settings pages covering query volume, tracker percentage, actor breakdown (device or source fallback), domain rankings, per-device detail, and runtime configuration
- Best-effort DoH/DoT bypass signal — flags devices that appear active on LAN but stop using local DNS, with higher confidence when encrypted-DNS bootstrap domains were seen
- REST API — JSON API for actors, devices, queries, activity, domains, lists, settings, and overrides
- Domain overrides — manually classify any domain when the lists get it wrong
- Persistent storage — SQLite (WAL mode), configurable retention, batched writes
- Configurable via UI — retention, list refresh interval, blocklist management all from the settings page
- Not a DNS blocker — Umberrelay labels domains but does not block or rewrite responses
- Not an IDS or firewall — it does not inspect packets deeply, enforce policy, or sit inline as a security appliance
- Not a packet capture tool — it works from DNS traffic plus passive discovery signals, not full payload capture
- Not complete network visibility — devices using DoH, DoT, hardcoded resolvers, or direct IP connections can bypass the DNS lens entirely
Umberrelay is strongest when you want a fully local, low-overhead privacy view by device without turning your network into a full security stack.
- If your main question is "which device is talking to trackers, and how much?", Umberrelay is a good fit.
- If your main question is "what protocol flow and payload details are on my network?", use DPI/flow tools (or run them alongside Umberrelay).
- If your main question is "block ads/trackers aggressively at DNS", a blocker-first tool (Pi-hole or AdGuard Home) is a better primary fit.
For Raspberry Pi deployment, ARM64 image builds on a dev machine, and live-Pi testing, see docs/DEPLOYMENT.md.
git clone https://github.com/baudsmithstudios/umberrelay.git && cd umberrelay
# Build and run with Docker
docker compose up -dThen open http://localhost:8080 in a browser.
If you are connecting from another device on your LAN, use http://<host-ip>:8080 instead.
Then point your router's DNS to the host running Umberrelay.
Umberrelay works best when it is the DNS server your network actually uses. In the common setup, that means pointing your router's LAN DNS setting at the host running Umberrelay so client devices send their queries through it.
Umberrelay is a passive DNS pass-through observer and classifier, so you can run it alongside blocker-first tools instead of choosing one or the other.
Recommended chain:
Clients / Router
│
▼
Umberrelay
│
▼
Pi-hole or AdGuard Home
│
▼
Upstream DNS
This lets Pi-hole or AdGuard do blocking while Umberrelay provides attribution-aware privacy reporting (including source-IP fallback when MAC is unavailable).
Caveats:
- Avoid DNS forwarding loops.
- Do not bind both services to the same
:53socket on the same host/interface without explicit port/interface separation.
Umberrelay needs minimal bootstrap config — everything else is managed through the web UI.
# config.toml
listen = "0.0.0.0:53"
upstream = ["1.1.1.1:53", "8.8.8.8:53"]
data_dir = "/data"
http_listen = "0.0.0.0"
http_port = 8080| Field | Default | Description |
|---|---|---|
listen |
0.0.0.0:53 |
DNS listener address |
upstream |
["1.1.1.1:53", "8.8.8.8:53"] |
Upstream DNS resolvers (sequential fallback) |
data_dir |
/data |
SQLite database and data directory |
http_listen |
0.0.0.0 |
Web UI and API bind address (host/interface only) |
http_port |
8080 |
Web UI and API port |
All fields are optional — Umberrelay runs with sane defaults if no config file exists.
These are managed through the web UI or API:
| Setting | Default | Description |
|---|---|---|
retention_days |
30 |
Days of query history to keep before purging (1-365) |
list_refresh_hours |
24 |
Hours between blocklist refresh cycles (1-168) |
Umberrelay uses four passive methods to build and maintain a device inventory:
| Method | What It Discovers |
|---|---|
| ARP table | IP-to-MAC mapping from /proc/net/arp (polled every 30s) |
| DHCP snooping | Hostnames from DHCP option 12 in client requests |
| mDNS | Hostnames from PTR/SRV records on 224.0.0.251:5353 |
| SSDP | Device presence from announcements on 239.255.255.250:1900 |
All discovery is passive — Umberrelay never sends probes or scans your network.
Umberrelay ships with three default blocklists:
| List | Category |
|---|---|
| Steven Black Unified | tracking |
| EasyPrivacy | analytics |
| Disconnect.me Tracking | tracking |
Lists are fetched on first run, cached to SQLite, and refreshed on a configurable interval. Add, remove, or disable lists from the settings page. Custom list URLs must be public http or https endpoints. Override individual domain classifications when you disagree with a list.
Umberrelay stores DNS query history, device identifiers discovered on the local network, domain classifications, and the runtime settings needed by the UI and API. By default, query history is retained for 30 days and then purged automatically.
All of that data stays local unless you choose upstream DNS resolvers or blocklists hosted elsewhere. Umberrelay does not ship analytics, cloud sync, or third-party telemetry.
The API is unauthenticated — see Access And Hardening.
- Read endpoints return JSON.
- Mutation endpoints accept
application/json. - Mutation endpoints return either JSON or an empty success status (
204 No Content/202 Accepted). - Errors return JSON in the form
{ "error": "message" }. /ui/...routes are internal SSR form handlers, not part of the public API contract.
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/health |
Health check |
GET |
/api/summary |
Dashboard stats (last 24h) |
GET |
/api/devices |
All devices with query stats |
GET |
/api/actors |
Attribution actors (known devices + source-IP fallback actors) with query stats |
GET |
/api/devices/{mac} |
Single device |
PUT |
/api/devices/{mac} |
Update device label |
GET |
/api/queries |
Query log (filterable by actor, device, domain, time range) |
GET |
/api/queries/stream |
Live query stream via Server-Sent Events (filterable by actor, device, domain, category) |
GET |
/api/activity |
Activity buckets for 24h, 7d, or 30d (optionally filter by actor, device, or source) |
GET |
/api/anomalies |
Known devices with unusual tracker rate or query volume spikes |
GET |
/api/bypass |
Best-effort signals for devices that may be bypassing local DNS visibility |
GET |
/api/domains |
Top domains with source list attribution and attribution-actor counts (last 24h) |
GET |
/api/settings |
Current settings |
PUT |
/api/settings |
Update settings |
GET |
/api/lists |
All classification lists |
POST |
/api/lists |
Add a list |
PUT |
/api/lists/{id} |
Enable or disable a list |
DELETE |
/api/lists/{id} |
Remove a list |
POST |
/api/lists/refresh |
Trigger immediate list refresh |
GET |
/api/lists/status |
Last classification-list refresh attempt/success/error status |
PUT |
/api/overrides/{domain} |
Set domain classification override |
DELETE |
/api/overrides/{domain} |
Remove domain override |
Mutation endpoints expect JSON request bodies:
| Endpoint | JSON Body |
|---|---|
PUT /api/devices/{mac} |
{ "label": "Living Room TV" } |
PUT /api/settings |
{ "retention_days": 30, "list_refresh_hours": 24 } |
POST /api/lists |
{ "url": "https://example.com/list.txt", "name": "Example", "category": "tracking" } |
PUT /api/lists/{id} |
{ "enabled": true } |
PUT /api/overrides/{domain} |
{ "category": "tracking" } |
Selected read endpoints return these JSON shapes:
| Endpoint | JSON Response |
|---|---|
GET /api/health |
{ "status": "ok" } |
GET /api/actors |
[{"key":"device:aa:bb:cc:dd:ee:ff","type":"device","name":"Living Room TV","device_mac":"aa:bb:cc:dd:ee:ff","source_ip":"","query_count":120,"tracker_percent":47.5},{"key":"source:10.0.0.7","type":"source","name":"Unattributed · 10.0.0.7","device_mac":"","source_ip":"10.0.0.7","query_count":25,"tracker_percent":12}] |
GET /api/activity |
[{"timestamp": 1711670400, "total": 42, "tracker": 18}] |
GET /api/anomalies |
[{"device_mac": "aa:bb:cc:dd:ee:ff", "device_name": "Living Room TV", "type": "tracker_spike", "current_value": 75, "average_value": 20, "delta": 55, "top_domain": "ads.example.com", "top_domain_category": "tracking", "top_domain_source_list": "Tracking List"}] |
GET /api/bypass |
[{"device_mac":"aa:bb:cc:dd:ee:ff","device_name":"Living Room TV","confidence":"likely","hint_domain":"dns.google","silent_minutes":180,"prior_query_count":42,"last_seen":1711670400,"last_query":1711659600}] |
GET /api/domains |
{ "total_devices": 12, "domains": [{"domain": "ads.example.com", "category": "tracking", "query_count": 120, "device_count": 4, "source_list": "Tracking List"}] } |
GET /api/settings |
{ "retention_days": 30, "list_refresh_hours": 24 } |
GET /api/lists/status |
{ "last_attempt_at": 1711670400, "last_success_at": 1711666800, "last_error": "..." } |
GET /api/queries/stream |
SSE query events with JSON data like {"id":42,"actor_key":"device:aa:bb:cc:dd:ee:ff","device_mac":"aa:bb:cc:dd:ee:ff","source_ip":"192.168.1.10","domain":"ads.example.com","query_type":"A","category":"tracking","timestamp":1711670400} |
Selected error responses use this JSON shape:
| Condition | JSON Response |
|---|---|
| Validation or request error | { "error": "message" } |
| Not found | { "error": "message" } |
| Internal or dependency error | { "error": "message" } |
GET /api/queries supports:
| Param | Description |
|---|---|
actor |
Filter by actor key (device:{mac} or source:{ip}) |
device |
Filter by device MAC |
domain |
Filter by domain |
from |
Start time (RFC3339) |
to |
End time (RFC3339, defaults to now) |
limit |
Results per page (default 100) |
offset |
Pagination offset |
When actor is set, it takes precedence over device.
GET /api/queries/stream supports:
| Param | Description |
|---|---|
actor |
Filter by actor key (device:{mac} or source:{ip}) |
device |
Filter by device MAC |
domain |
Filter by domain |
category |
Filter by category (tracking, advertising, analytics, telemetry, malware, uncategorized) |
after |
Only emit events with query ID greater than this cursor |
limit |
Batch size per poll (default 100, max 500) |
When actor is set, it takes precedence over device.
GET /api/activity supports:
| Param | Description |
|---|---|
actor |
Filter by actor key (device:{mac} or source:{ip}) |
device |
Filter by device MAC |
source |
Filter by unattributed source IP |
range |
Time window: 24h (default, hourly buckets), 7d (daily buckets), or 30d (daily buckets) |
Filter precedence is actor, then source, then device.
GET /api/domains returns an object with total_devices plus a domains array. Each domain item includes:
GET /api/domains supports:
| Param | Description |
|---|---|
limit |
Results per page (default 100, max 1000) |
| Field | Description |
|---|---|
domain |
Domain name |
category |
Stored classification category |
query_count |
Number of matching queries in the last 24h |
device_count |
Distinct attribution actors that queried the domain in the last 24h (device MACs + source-IP fallback actors) |
source_list |
Best-effort attribution for the matching blocklist, or manual / unknown |
The checked-in docker-compose.yml builds from source and is aimed at local development and simple local Docker runs. It uses network_mode: host so Umberrelay can see DNS traffic and the ARP table, mounts config read-only, and stores /data in a named volume.
For Raspberry Pi deployment — building an ARM64 image on a faster machine and transferring it to the Pi — see the workflow in docs/DEPLOYMENT.md. The Pi runs its own compose file pinned to the prebuilt image.
- Linux host — device attribution depends on Linux networking details such as
/proc/net/arp - Port access — Umberrelay needs to bind DNS on port
53; passive listeners also use UDP67,5353, and1900 - Host networking — the provided Docker deployment uses
network_mode: hostso DNS and multicast traffic are visible to the container - Trusted network placement — the web UI and API are unauthenticated; see Access And Hardening
docker compose up -d # start
docker compose logs -f # logs
docker compose down # stopThe Dockerfile uses a two-stage build: compile in golang:1.26-alpine, run in alpine:3.19 with just the binary and CA certificates.
- A device is missing — confirm the device is actually using Umberrelay for DNS; devices with hardcoded resolvers or encrypted DNS may never appear
- A bypass signal is unexpected —
/api/bypassis best-effort, not packet-level proof; validate with direct DNS tests (dig @<umberrelay-ip> ...) and your router DNS policy - Routed client is unattributed — across subnets/VLANs, Umberrelay may only have source IP (no MAC); verify the client appears as a source fallback actor in the Devices page or
/api/actors - Devices appear but names are generic — hostname enrichment depends on passive DHCP, mDNS, and SSDP traffic; some devices simply do not advertise much
- Tracker labels look wrong — classifications come from community blocklists; use domain overrides when a list is too broad or out of date
- Some traffic is invisible — Umberrelay does not see direct IP traffic or DNS that bypasses it, so partial visibility is an expected limitation in some networks
DNS Client
│
▼
DNS Listener (UDP + TCP)
│
├─ Forward to upstream (sequential fallback)
├─ Emit QueryRecord to channel
│
▼
Pipeline Writer (async, batched)
│
├─ Resolve IP → MAC via Tracker
├─ Classify domain via Manager
└─ Batch write to SQLite
↑
Device Tracker (goroutine) ─────────────┘
├─ ARP poller (30s)
├─ DHCP listener (port 67)
├─ mDNS listener (224.0.0.251:5353)
└─ SSDP listener (239.255.255.250:1900)
Classification Manager (goroutine)
├─ Fetch lists on startup
├─ Cache to SQLite
└─ Periodic refresh
Purge Loop (goroutine)
└─ Delete queries older than retention_days (daily)
Web Server
├─ Home, Devices, and Settings pages
├─ Per-device detail view
├─ HTMX fragment handlers for settings, overrides, and list management
└─ REST API
- DNS Listener — dual-stack UDP/TCP, forwards to upstream with sequential fallback, emits records non-blocking (drops on channel full rather than blocking DNS)
- Pipeline Writer — batches queries (100 per batch or 1s flush), enriches with device MAC and domain category before writing
- Classification Manager — atomic pointer swap on refresh, lock-free reads on the hot path
- SQLite — WAL mode,
NORMALsynchronous; schema auto-applied on startup
- No blocking — Umberrelay observes and classifies but does not block or modify DNS responses
- No authentication — the web UI and API are unauthenticated; see Access And Hardening below
- No telemetry — Umberrelay does not send analytics or cloud telemetry; outbound network traffic is limited to DNS forwarding and blocklist fetches
- Passive discovery — device identification uses only broadcast/multicast traffic and the local ARP table
- Parameterized queries — all SQL uses parameterized statements
- Input validation — API and UI mutation handlers validate JSON bodies, form inputs, list URLs, and allowed categories
Umberrelay has no built-in authentication, so the web UI and REST API are reachable by anything that can connect to the HTTP port. For any deployment beyond a single-user host, run a reverse proxy in front of it rather than exposing the app directly.
Recommended pattern:
- Bind Umberrelay to
127.0.0.1:8080(or a dedicated internal interface) - Run a reverse proxy (Caddy, nginx, Traefik) on the LAN-facing side
- Terminate TLS at the proxy
- Add authentication at the proxy (HTTP basic auth, an OAuth2 proxy, or your SSO)
- Optionally add rate limiting or IP allowlists
This keeps the unauthenticated surface off the network and gives you one place to manage TLS, auth, and access policy. It also simplifies firewall rules, since you expose a single well-known port (e.g., 443) instead of the app's HTTP port directly.
Umberrelay fills a narrow niche: a fully local DNS forwarder that turns query logs into per-device privacy reporting. It is not a blocker, packet inspector, or firewall — that is the deliberate tradeoff, and it keeps the scope simple, Pi-friendly, and local-first.
| Feature | Umberrelay | Pi-hole | AdGuard Home | Firewalla | ntopng |
|---|---|---|---|---|---|
| DNS query logging | Yes | Yes | Yes | Some | Some |
| Per-device attribution | Yes | Yes | Yes | Yes | Yes |
| Tracker / blocklist classification | Yes | Yes | Yes | Some | Some |
| Privacy-focused per-device summaries | Yes | No | No | Some | No |
| DNS blocking | No | Yes | Yes | Yes | No |
| Flow / DPI visibility | No | No | No | Yes | Yes |
| Fully local, self-hosted | Yes | Yes | Yes | No | Yes |
| Pi-friendly deployment | Yes | Yes | Yes | No | Mixed |
| Open source | Yes | Yes | Yes | No | Yes |
| Component | Library | Description |
|---|---|---|
| Language | Go | 1.26 |
| DNS server | miekg/dns | Full-featured DNS library |
| Database | modernc.org/sqlite | Pure-Go SQLite driver (CGo-free) |
| Config parsing | BurntSushi/toml | TOML configuration file parser |
| Frontend interactivity | HTMX | Server-rendered HTML with inline fragment swaps |
| Frontend styling | Pico CSS | Minimal classless CSS framework |
| Charts | uPlot | Fast canvas time-series plots |
| Containerization | Docker | Multi-stage build |
