Self-hosted node
The self-hosted node, opdns-node, is the same resolver as the cloud:
opdns-edge’s listeners and policy engine in one process, supervising its
own Unbound, with a SQLite file for logs and a small status page. Put it on
a Raspberry Pi, a home server or a NAS and point your network at it.
Why run one:
- It keeps working when the internet or opdns is down. Filtering uses the last profile and lists it received.
- Logs stay at home. Queries your devices send to the node are logged in its SQLite file and nowhere else.
- Lower latency for everything on your network.
| Enrolled | Standalone | |
|---|---|---|
| Account | required | none |
| Profile | from the dashboard, pushed to the node over its link | in the node’s config file |
| Lists | the signed list artifact opdns publishes | the same public artifact |
| Logs | the node’s SQLite; plus the cloud’s logs for the profile if you choose | the node’s SQLite |
| Manage it from | the dashboard (logs and analytics are read through the link); a local status page | the config file; a local status page |
| When opdns is unreachable | keeps resolving with the last profile and lists | unaffected |
A node that is neither enrolled nor given a standalone profile does not serve DNS at all: it says how to enrol and exits. The beta ships enrolled mode. Standalone mode works, with a small local API but no editing page yet; see Standalone mode.
Either way, the node answers only devices on your own network unless you allow more; see Network access.
A node serves one profile in the beta. Per-client profiles (different profiles for different devices on your LAN) come with Phase 4’s local network features; local names from DHCP leases and private PTR records are already answered (Local names).
List updates and rollbacks
Section titled “List updates and rollbacks”The node installs a list artifact only if it verifies with a trusted
Ed25519 key and matches the announced hash. What the cloud announces is
what an enrolled node runs: a lists changed notice over the link, or the
lists of a profile pull, installs the named version even when it is
older than the one in use. That is how an operator’s
rollback of a bad list build reaches your node.
Enrolled nodes follow only fleet-wide versions, never a build still in its
canary.
The last announced version is kept in lists_announced.json in the data
directory. A notice for an older version that arrives within 30 seconds of
a newer announcement is treated as a replay of an old message and ignored,
not as a rollback. A standalone node never goes back silently: if polling
finds an older latest.json than the artifact it has, it refuses it,
keeps its current lists and shows the error in opdns-node status.
Profile signing keys
Section titled “Profile signing keys”An enrolled node checks that the profile it receives comes from opdns:
every profile the cloud sends is signed with Ed25519 (profile_signature
over the profile’s id, version and content). The node trusts:
- the keys the cloud announces: at enrolment, then in every profile
response that verifies, so a key rotation needs nothing from you. A node
enrolled before the cloud signed profiles learns the key on its first
pull that announces one. Learnt keys are kept in
<data_dir>/profile_keys.json, backed up with the node and forgotten on unenrol; - the keys you pin with
cloud.profile_public_keys(OPDNS_CLOUD_PROFILE_PUBLIC_KEYS, hex), if you want trust not to depend on what the node learnt at enrolment.
Once any key is trusted, an unsigned or badly signed profile is refused:
the node keeps resolving with its last good profile, opdns-node status
and the local page show the error, and
opdns_node_profile_signature_failures_total counts it. A key the cloud
stops announcing stays trusted until the next pull confirms the new set.
The first key is trusted on first use: a node that is enrolled through a
compromised path would learn the wrong key, which is what pinning
prevents (an open decision, like the rest of the trust model). If the
cloud’s key is ever lost and replaced, stop the node, delete
profile_keys.json, and start it again so it learns the new key.
Local names
Section titled “Local names”The node answers your network’s own names itself, in both modes, without asking the internet:
- Local domains,
local.domains(defaulthome.arpa, RFC 8375): hosts fromlocal.hosts(name=address[,address], a bare label is answered under every local domain) and host names from DHCP lease files inlocal.lease_files(dnsmasq, ISC dhcpd or Kea CSV, detected; polled everylocal.lease_poll, 30 seconds). Static hosts win over leases.local.hostsis reloaded onSIGHUP. - Reverse lookups of private addresses (RFC 1918, 169.254/16, fc00::/7, fe80::/10): PTR answers from the same table.
A name or private address the node does not know is answered NXDOMAIN
locally, or asked of your router when local.forward names it
(conditional forwarding). Either way, private reverse lookups never leave
your network. Local names are answered after the profile’s policy, so a
rewrite of the same name still wins over them.
The local page shows how many names are answered and when the leases were
last read.
The default is on with nothing configured: home.arpa names you have not
declared get NXDOMAIN, and reverse lookups of private addresses are kept
local. Answering the local names by default is an open decision. A local
domain outside home.arpa, .lan, .home, .internal and the other
local suffixes may be caught by rebinding protection. With
local.device_names on (the default), queries from clients that send no
device name are logged under their lease or host name
(Logs on your node).
IPv6 for upstream queries
Section titled “IPv6 for upstream queries”Unbound can ask authoritative servers over IPv4 and IPv6. On a host
without working IPv6, trying IPv6 first makes cold lookups slow and
failures more frequent, so the node checks the host at start and on every
reload (SIGHUP): it asks the kernel for a route to a public IPv6
address, without sending a packet.
| The host has | Unbound is told |
|---|---|
| no IPv6 route | IPv4 only (do-ip6: no) |
| a route, but only a private or unique-local IPv6 source address | prefer IPv4 (prefer-ip4: yes), as the PoPs without an IPv6 source |
| a public IPv6 source address | both families |
unbound.ipv6 overrides the check: auto (default), on, prefer-ip4
or off. A change of the result restarts Unbound. The local page and
/api/status (unbound.ipv6) show the route, the source address and
what Unbound uses. This concerns only the node’s own queries to the
internet (and only a node that runs its own Unbound, not one with
unbound.spawn: false); your devices can still query the node over IPv6.
Local root zone
Section titled “Local root zone”Like every opdns PoP, the node’s Unbound keeps its own copy of the DNS root zone (RFC 8806), on by default. Root referrals come from the copy, so a lookup that is not cached starts at the top-level domain’s servers instead of asking a root server: slightly faster, and while the copy is valid the root servers do not see which top-level domains your network looks up.
- The copy is
<data_dir>/unbound/root.zone(about 2 MB). Unbound refreshes it on the zone’s own timers, from the sources indns.root_zone_sources: by default the root servers that allow transfers (b, c, d, f, g and k, plus ICANN’s xfr.lax and xfr.cjr, over IPv4 and IPv6) and ICANN’s HTTPS copy atwww.internic.net. - Every copy is checked against its ZONEMD digest (and its DNSSEC signatures while validation is on) before it is used.
- A missing, rejected or expired copy never stops resolution: Unbound
simply asks the root servers, as any resolver does.
/healthzdoes not depend on it. - The status page and
opdns-node statusshow the copy’s serial and age (unbound.root_zonein/api/status) and warn once it is two days old; the root zone normally changes about twice a day. Withpage.metricson,/metricshasopdns_edge_root_zone_loaded,opdns_edge_root_zone_serialandopdns_edge_root_zone_updated_timestamp_seconds, the same series as the PoPs.
Transfers need outbound TCP port 53 (to the addresses) and HTTPS (to the
URL). On a network that only lets DNS out to one server, set
dns.root_zone_sources to a local server that serves the root zone by
zone transfer, or turn the copy off with dns.root_zone: false (the
environment variables are OPDNS_DNS_ROOT_ZONE and
OPDNS_DNS_ROOT_ZONE_SOURCES). Both keys take effect on restart; see the
configuration reference. A node
with unbound.spawn: false ignores them.
What you need
Section titled “What you need”- Linux on
amd64orarm64with Docker, or the static binary forlinux/amd64,linux/arm64ordarwin/arm64. On Linux the binary runs as a hardened systemd service from the unit in the repository. - For the binary: Unbound installed on the host. The container includes it.
- Nothing to configure for an enrolled node: the list signing key comes from the cloud.
- Port 53 (UDP and TCP) free on the host, and 8053 for the status page.
- Memory: the full list artifact is sized for under 1 GB. A smaller variant for 1 GB devices is planned (SH-055).
What opdns learns from your node
Section titled “What opdns learns from your node”From an enrolled node, over its one outbound connection to
link.opdns.net: the node’s id; its versions (node, Unbound, profile,
lists); uptime and last sync time; SQLite file size, oldest record time and
pending and dropped record counts; clock offset and whether Unbound is
healthy; connection times and its public IP address. Never the
queries it resolves or the logs it produces, except as answers to your own
dashboard requests (relayed through opdns in transit, not stored). When
such an answer lists destination addresses, the control plane adds their
countries on the way, since the node has no GeoIP data
(Analytics from your node).
From a standalone node: downloads of the public list artifact (IP address and time), kept 14 days in operational logs.
The details are in the privacy policy, section 7 and on Architecture.
Licence
Section titled “Licence”The node is source-available under the Source First License 1.1: free for non-commercial use such as your home network. A business running it needs a commercial licence. See Licensing.
Next: install with Docker or the static binary.