docs: rewrite for people running nxdns; query-history refusals name their remedy and a refused start gives no wrong hints

This commit is contained in:
2026-10-21 17:03:00 +02:00
parent fbc8fd7882
commit c21e5a9542
31 changed files with 1186 additions and 3877 deletions
-287
View File
@@ -1,287 +0,0 @@
# Your first nxdns
This page takes you from a checkout of the repository to a running nxdns that answers DNS queries, blocks domains from a real blocklist, and shows you the web interface. It runs on unprivileged ports in a scratch directory, so nothing on your machine changes and nothing needs root. At the end you stop the server and delete the directory.
Follow the steps in order. Each one says what it did.
Every command below was executed on x86_64 Linux with Zig 0.16.0, Node.js 24.14.1, dig 9.20.26 and curl 8.21.0. Steps 2, 4 to 11, 13 and 14 were re-run end to end for this revision, and the transcripts are that run's output with the tutorial directory substituted. Two things were not re-run: the browser page in step 12 — its endpoints were exercised, the page itself was not opened — and the `npm` build in step 1, whose `admin/dist` was already on disk and is the one the binary under test embeds. The ZON block at the end of step 14 was checked with `nxdns check --config` rather than started.
## What you need
- Zig 0.16.0 and Node.js 24, both on your `PATH`.
- `dig` (from bind-tools or dnsutils) and `curl`.
- Working internet access: nxdns queries an upstream resolver over HTTPS and downloads a 3 MB blocklist.
- Two terminals. The server runs in the first one; you type into the second.
Build commands run from the repository root. The server and the queries use `~/nxdns-tutorial` for everything they write.
## 1. Build the web interface
```sh
cd admin && npm ci && npm run build && cd ..
```
This produces `admin/dist`. Do not skip it. A plain `zig build` embeds `admin/dist-placeholder`, a one-page status stub, and you would reach step 12 and find no admin interface there.
## 2. Build nxdns
```sh
zig build -Dadmin-dist=admin/dist
```
The binary is `zig-out/bin/nxdns`, with `admin/dist` embedded in it. SQLite and mbedTLS are vendored and built by this command, so the first build takes a while; later builds are cached.
## 3. Write a configuration file
```sh
mkdir -p ~/nxdns-tutorial
cat > ~/nxdns-tutorial/config.zon <<'EOF'
.{
.dns = .{ .bind_ipv4 = "127.0.0.1", .bind_ipv6 = "::1", .port = 15353 },
.web = .{ .bind = "127.0.0.1", .port = 8080 },
.groups = .{
.{ .name = "default" },
},
.upstreams = .{
.{ .url = "https://cloudflare-dns.com/dns-query" },
},
}
EOF
```
Four things are set, and the rest of nxdns keeps its defaults.
DNS is on port 15353 instead of 53, and both listeners are bound to localhost. Port 53 is privileged: binding it needs root or `CAP_NET_BIND_SERVICE`, which is an install decision, not a first-run decision. A real install is covered in [how-to/install-with-systemd.md](../how-to/install-with-systemd.md).
The `default` group and one enabled upstream are the two things nxdns will not start without. Every client that nxdns has never seen is assigned to `default`, and with no usable upstream there is nowhere to send a query it cannot answer itself, so a configuration missing either one is rejected. Whichever command reads the file says the same thing and stops the same way: `run`, `nxdns check` and `nxdns import` all print the problem and exit 2.
This tutorial loads the file into the database once and then runs nxdns against the database, which is what the packaged systemd unit does. There is a second way to run nxdns, where the file itself stays the configuration; step 14 shows what changes.
## 4. Check the configuration before starting
```sh
zig-out/bin/nxdns check --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
```
```
checking configuration file /home/you/nxdns-tutorial/config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
```
`check` parses the file, validates it, and contacts each upstream to confirm it answers. It exits 2 when it found something that has to be fixed and 0 otherwise. It writes nothing and starts no listener, so you can run it as often as you like.
## 5. Load it into the database
```sh
zig-out/bin/nxdns import ~/nxdns-tutorial/config.zon --data-dir ~/nxdns-tutorial/data
```
```
info(migrations): config.db migrated from schema version 0 to 1
imported /home/you/nxdns-tutorial/config.zon
```
The data directory did not exist; nxdns created it at mode 0700 along with `config.db`. From here the database holds the configuration, and you will change it through the API rather than by editing the file again.
## 6. Start the server
In your first terminal:
```sh
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data
```
```
info(querylog_schema): created querylog database '/home/you/nxdns-tutorial/data/querylog.db'
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(nxdns): authority: database
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 127.0.0.1:8080
```
No `--config` here, and that is the point: `authority: database` says the database is the configuration and no file was opened at all. The file you wrote in step 3 has done its job.
Leave this terminal running and switch to the second one.
## 7. Resolve a name
```sh
dig @127.0.0.1 -p 15353 example.com A +noall +answer
```
```
example.com. 229 IN A 172.66.147.243
example.com. 229 IN A 104.20.23.154
```
nxdns had no answer cached, so it forwarded the query to `https://cloudflare-dns.com/dns-query` over HTTPS and returned what came back. You now have a working resolver. It blocks nothing yet: the log line in step 6 said `0 of 0 sources loaded`.
## 8. Add a blocklist source
```sh
curl -s -X POST http://127.0.0.1:8080/api/blocklists \
-H 'Content-Type: application/json' \
-d '{"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","name":"StevenBlack hosts"}'
```
```json
{"id":1,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","name":"StevenBlack hosts","enabled":true,"is_suggested":false}
```
The API is unauthenticated here because the configuration set no `web.password`. That is fine for a localhost tutorial and wrong for anything else; see [how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md).
Note the `"id":1` in the response. You need it in the next step.
## 9. Attach the source to the `default` group
A blocklist source belongs to the installation. Which groups use it is a separate decision, which is what lets one group get a strict list and another get none. A source that is attached to no group is downloaded and then filters nothing.
You are in that state right now, between the previous step and this one, and it is legal rather than wrong — creating a source and attaching it afterwards is the normal order, which is why the API accepted it without complaint. On a stopped server `nxdns check` names such a source in a `WARN` line and still exits 0, so a list that silently blocks nothing is something you can find out about later. Attaching it now is what makes it take effect.
Ask which groups exist:
```sh
curl -s http://127.0.0.1:8080/api/groups
```
```json
{"groups":[{"id":1,"name":"default","safe_search":false}]}
```
The `default` group is id 1. Give it the source you just created — the request body is the complete set of sources for that group, so sending `[1]` replaces whatever was there:
```sh
curl -s -X PUT http://127.0.0.1:8080/api/groups/1/sources \
-H 'Content-Type: application/json' \
-d '{"source_ids":[1]}'
```
```json
{"source_ids":[1]}
```
## 10. Download the list
Adding a source registers it; it does not fetch it. Ask for a refresh:
```sh
curl -s -X POST http://127.0.0.1:8080/api/blocklists/update
```
```json
{"sources":[{"id":1,"state":"ok","loaded":true,"last_attempt":1786629237,"last_success":1786629238,"url":"https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts","last_error":"","domains":97648,"wildcards":0,"exceptions":0,"skipped_regex":0,"skipped_unsupported":0}]}
```
The four zeros describe this particular download, not the hosts format. `wildcards` counts entries covering a name and its subdomains, `exceptions` counts the `@@` lines an Adblock Plus list uses to lift a name another list blocks, and `skipped_regex` counts the regex lines nxdns declines to take from a downloaded list. `skipped_unsupported` counts lines nxdns cannot translate into a DNS decision, and a large value next to a small `domains` means the list targets browsers rather than DNS. Only `exceptions` is Adblock-Plus-only: a hosts list can carry regex lines, `*.`-prefixed wildcards, and bare sink addresses that count as unsupported. This one carries none of them.
The download is about 3 MB and takes a few seconds. Watch the first terminal until this appears:
```
info(blocklist_manager): blocklist snapshot generation 6: 1 of 1 sources loaded, 2523973 bytes
```
`1 of 1 sources loaded` is the line to wait for. nxdns builds each blocklist snapshot in full and swaps it in atomically, so queries keep being answered from the previous snapshot the whole time the new one is being built. After this, the domain count in the JSON above — 97648 on the day this was run — is live.
From here on, the list is on disk under `~/nxdns-tutorial/data/blocklists`. Restarting nxdns does not re-download it.
## 11. Watch a domain get blocked
```sh
dig @127.0.0.1 -p 15353 doubleclick.net A +noall +answer
```
```
doubleclick.net. 5 IN A 0.0.0.0
```
`0.0.0.0` with a TTL of 5 is a block, not an answer. The default block response is the zero address and the default block TTL is 5 seconds, so a client that caches the answer forgets it quickly after you unblock something.
A name that is not on the list still resolves normally:
```sh
dig @127.0.0.1 -p 15353 wikipedia.org A +noall +answer
```
```
wikipedia.org. 130 IN A 185.15.58.224
```
One thing to know before you try other names: an entry in a hosts list blocks exactly the name it names. `doubleclick.net` is on this list, and so are `ad.doubleclick.net` and `www.google-analytics.com`. `ads.doubleclick.net` is not on it, and nxdns does not block it — the entry for the parent says nothing about the child. Two things do walk up the parent chain, and neither is in play here: a rule you write yourself, with a wildcard pattern such as `*.doubleclick.net`, and an Adblock Plus list's `||doubleclick.net^`, which covers the name and everything under it. This list is a hosts file, so when you pick a domain to test against it, pick one that is literally in the file.
## 12. Open the web interface
Visit <http://127.0.0.1:8080> in a browser. This is the single-page application you built in step 1, served out of the binary. The Overview shows query and block counts, and the Protection page's Sources tab shows the source you added with its domain count. (The endpoints behind those two pages were checked while writing this; the browser page itself was not opened on the verification host.)
## 13. Stop it
In the first terminal, press Ctrl-C.
```
info(nxdns): shutting down
```
nxdns catches SIGINT and SIGTERM, stops serving and exits 0.
Start it again with the same command as in step 6 and read the first log lines:
```
info(blocklist_manager): blocklist snapshot generation 1: 1 of 1 sources loaded, 2523973 bytes
info(nxdns): authority: database
```
The blocklist came off disk rather than the network. The source you added, the group it is attached to, and the query log all survived the restart because they live in `~/nxdns-tutorial/data`, which is what `authority: database` means in practice.
Press Ctrl-C again to stop this second process. Nothing is listening on 15353 or 8080 now, and nothing of nxdns is running.
## 14. See what the other mode does
You have been running in database mode. The alternative is to hand the same file back as the configuration, which is what `--config` means:
```sh
zig-out/bin/nxdns run --data-dir ~/nxdns-tutorial/data --config ~/nxdns-tutorial/config.zon
```
```
reconciled '/home/you/nxdns-tutorial/config.zon': sources +0 ~0 -1; group_sources +0 ~0 -1;
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(nxdns): authority: file (/home/you/nxdns-tutorial/config.zon)
info(nxdns): nxdns <version> serving on udp [::1]:15353 udp 127.0.0.1:15353 tcp [::1]:15353 tcp 127.0.0.1:15353; 1 upstream(s); blocklist generation 1
info(blocklist_manager): pruned orphaned blocklist file 1.allow
info(blocklist_manager): pruned orphaned blocklist file 1.wild
info(blocklist_manager): pruned orphaned blocklist file 1.list
```
**Read that first line.** The blocklist source is gone. That is not a bug — it is the whole contract. The file you wrote in step 3 never mentioned a blocklist source, and in file mode the file is the complete statement of what the configuration is, so anything the database holds that the file does not name is removed at every start. The reconcile said so in one line before doing it.
The three `pruned` lines are the rest of that removal: with the row gone, the compiled files it owned belong to nobody, so the sweep that runs at every start deletes them. The three names are the three bodies one source compiles into — exact domains, wildcards, and the exceptions an Adblock Plus list can lift. The query log is untouched; what changed is the configuration, and it now matches the file exactly.
Neither mode is the "advanced" one. Database mode suits a box someone administers through the web interface. File mode suits a file kept in git and deployed by a tool, where the deployed file being what is running matters more than clicking. To have kept the blocklist here, you would put it in the file:
```zon
.blocklist_sources = .{
.{ .url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts", .name = "StevenBlack hosts" },
},
.group_sources = .{
.{ .group = "default", .source_url = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts" },
},
```
Ctrl-C to stop it.
## What you have now
A resolver that answers real queries, a real blocklist of about 98000 domains attached to the default group, a query log, and a web interface — all inside one directory you can delete:
```sh
rm -rf ~/nxdns-tutorial
```
You also saw the three things that surprise people most: a blocklist source does nothing until a group uses it, which authority a start runs under is printed rather than guessed, and in file mode anything the file does not name is removed at the next start.
## Where to go next
- [how-to/install-with-systemd.md](../how-to/install-with-systemd.md) — the same thing as a real service on port 53, including a Raspberry Pi 5.
- [how-to/set-up-admin-authentication.md](../how-to/set-up-admin-authentication.md) — put a password on the web interface before it leaves localhost.
- [reference/configuration.md](../reference/configuration.md) — every field you did not set.
- [explanation/configuration-model.md](../explanation/configuration-model.md) — why there are two authority modes and what each one is for.
+153
View File
@@ -0,0 +1,153 @@
# Get started
Run nxdns on your own computer, point a query at it, and watch it block an ad domain from a real blocklist. Everything happens in one directory and on unprivileged ports, so nothing on your machine changes and nothing needs root. At the end you stop it and delete the directory.
You need a Linux machine (x86_64 or aarch64), `curl`, and `dig` (from bind-tools or dnsutils). nxdns is a single static binary with no dependencies.
## 1. Download
Make a directory to work in, download the release for your machine with its checksum file, and check the download:
```sh
mkdir ~/nxdns-try && cd ~/nxdns-try
curl -fLO https://git.mial.net/mokhtar/nxdns/releases/download/v<version>/nxdns-<version>-x86_64-linux-musl.tar.gz
curl -fLO https://git.mial.net/mokhtar/nxdns/releases/download/v<version>/SHA256SUMS.txt
sha256sum -c --ignore-missing SHA256SUMS.txt
```
```
nxdns-<version>-x86_64-linux-musl.tar.gz: OK
```
On a Raspberry Pi or another 64-bit ARM machine, replace `x86_64` with `aarch64` in the URL and in the commands below. The checksum file is signed; [Verify a release](../how-to/verify-a-release.md) shows how to check the signature as well, which is worth doing before a real install.
Unpack it and go into the directory it creates:
```sh
tar -xzf nxdns-<version>-x86_64-linux-musl.tar.gz
cd nxdns-<version>-x86_64-linux-musl
./nxdns version
```
```
nxdns <version>
zig 0.16.0
```
## 2. Write a configuration
nxdns needs two things before it starts: a group called `default`, which every device belongs to until you say otherwise, and an upstream resolver to forward the queries it does not answer itself. Create `config.zon`:
```sh
cat > config.zon <<'EOF'
.{
.dns = .{ .bind_ipv4 = "127.0.0.1", .bind_ipv6 = "::1", .port = 5300 },
.web = .{ .bind = "127.0.0.1", .port = 8080 },
.groups = .{ .{ .name = "default" } },
.upstreams = .{ .{ .url = "https://cloudflare-dns.com/dns-query" } },
}
EOF
```
DNS listens on port 5300 instead of 53, and both DNS and the web interface listen on this machine only. Port 53 needs root, which is a decision for a real install, not for a first try. Every field you did not set keeps its default; the [configuration reference](../reference/configuration.md) lists them all.
Check the file:
```sh
./nxdns check --config config.zon
```
```
checking configuration file config.zon
OK upstreams[0] https://cloudflare-dns.com
OK: no problems found
```
`check` validates the file and sends one real query to each upstream to confirm it answers. It changes nothing, so you can run it as often as you like.
## 3. Load it and start
Load the file into a new data directory, then start the server:
```sh
./nxdns import config.zon --data-dir data
./nxdns run --data-dir data
```
```
info(migrations): config.db migrated from schema version 0 to 1
imported config.zon
info(querylog_schema): created querylog database 'data/querylog.db'
info(blocklist_manager): blocklist snapshot generation 1: 0 of 0 sources loaded, 231 bytes
info(nxdns): authority: database
info(nxdns): nxdns <version> serving on udp [::1]:5300 udp 127.0.0.1:5300 tcp [::1]:5300 tcp 127.0.0.1:5300; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 127.0.0.1:8080
```
`authority: database` means the configuration now lives in `data/config.db`, and you change it from the web interface. The file has done its job. (nxdns can also treat the file as the configuration at every start; [the configuration model](../explanation/configuration-model.md) explains both modes.)
Leave the server running in this terminal and open a second one.
## 4. Resolve a name
```sh
dig @127.0.0.1 -p 5300 example.com A +noall +answer
```
```
example.com. 214 IN A 104.20.23.154
example.com. 214 IN A 172.66.147.243
```
nxdns forwarded the query to Cloudflare over HTTPS and returned the answer. It blocks nothing yet: the log said `0 of 0 sources loaded`.
## 5. Add a blocklist
Open <http://127.0.0.1:8080> in a browser. There is no password yet, so the Overview opens straight away; [Set up admin authentication](../how-to/set-up-admin-authentication.md) adds one before the interface leaves your machine.
1. Go to **Protection** and open the **Sources** tab.
2. Under **Add source**, set **URL** to `https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts` and **Name** to `StevenBlack hosts`, then press **Add source**.
3. Open the **Groups** tab. The `default` group is selected. Under **Assigned sources**, tick **StevenBlack hosts** and press **Save sources**.
4. Back on the **Sources** tab, press **Update now**.
After a few seconds the source shows its size, something like `71,494 domains · 0 wildcards · 0 exceptions`, and the server log says `1 of 1 sources loaded`.
Step 3 matters. A source belongs to the whole installation, and each group picks the sources it uses, which is how one group of devices can get a strict list while another gets none. A source no group uses is downloaded and blocks nothing.
## 6. Watch a domain get blocked
In the browser, go to **Activity** and open the **Live** tab. Then, in your second terminal:
```sh
dig @127.0.0.1 -p 5300 doubleclick.net A +noall +answer
```
```
doubleclick.net. 5 IN A 0.0.0.0
```
`0.0.0.0` is the block answer, with a short TTL so a device forgets it soon after you unblock a domain. The query appears in the Live tab at once, marked **Blocked**.
A domain that is not on the list still resolves normally:
```sh
dig @127.0.0.1 -p 5300 wikipedia.org A +short
```
The **History** tab keeps every query. It is written to disk about once a minute, so give it that long. Then select `doubleclick.net` to see why it was blocked: the decision, the reason (`Blocklist (domain)`), the entry that matched and the list it came from (`StevenBlack hosts`).
A hosts list blocks exactly the names it lists. `doubleclick.net` and `ad.doubleclick.net` are both on this one, but a name the file does not list resolves even when its parent is blocked. To block every name under a domain, add a wildcard rule such as `*.doubleclick.net` under **Rules** on the group; it covers the names below `doubleclick.net`, not `doubleclick.net` itself. An Adblock Plus list's `||doubleclick.net^` entries cover both.
## 7. Stop and clean up
Press Ctrl-C in the first terminal. nxdns logs `shutting down` and exits. Then remove the directory:
```sh
rm -rf ~/nxdns-try
```
## Next
- Install it for real, on port 53 for your whole network: with [Docker](../how-to/install-with-docker.md), with [systemd](../how-to/install-with-systemd.md), or on [NixOS](../how-to/install-with-nix.md).
- [Set up admin authentication](../how-to/set-up-admin-authentication.md) before the web interface is reachable from other machines.
- [The configuration model](../explanation/configuration-model.md) explains the choice between configuring nxdns in the web interface and keeping a file in git.