milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 4m55s
CI / frontend (push) Successful in 39s
CI / cross (push) Successful in 7m57s
CI / docker (push) Failing after 1h10m42s

This commit is contained in:
2026-08-07 17:55:59 +02:00
parent 9b12dbaaa0
commit c50c6d285a
57 changed files with 2926 additions and 126 deletions
+44
View File
@@ -5,3 +5,47 @@
pub const reference_api_md = @embedFile("reference/api.md");
pub const reference_configuration_md = @embedFile("reference/configuration.md");
pub const reference_cli_md = @embedFile("reference/cli.md");
pub const tutorial_first_run_md = @embedFile("tutorial/first-run.md");
pub const howto_back_up_and_restore_md = @embedFile("how-to/back-up-and-restore.md");
pub const howto_enable_doh_and_dot_md = @embedFile("how-to/enable-doh-and-dot.md");
pub const howto_install_with_docker_md = @embedFile("how-to/install-with-docker.md");
pub const howto_install_with_systemd_md = @embedFile("how-to/install-with-systemd.md");
pub const howto_measure_performance_md = @embedFile("how-to/measure-performance.md");
pub const howto_set_up_admin_authentication_md = @embedFile("how-to/set-up-admin-authentication.md");
pub const howto_troubleshoot_md = @embedFile("how-to/troubleshoot.md");
pub const howto_upgrade_md = @embedFile("how-to/upgrade.md");
pub const Page = struct {
/// Repo-relative path, so a failing assertion names the file to edit.
path: []const u8,
text: []const u8,
};
/// Every page this module embeds. A guard that must hold for all of them walks
/// this table instead of naming each decl.
pub const pages: []const Page = &.{
.{ .path = "docs/reference/api.md", .text = reference_api_md },
.{ .path = "docs/reference/configuration.md", .text = reference_configuration_md },
.{ .path = "docs/reference/cli.md", .text = reference_cli_md },
.{ .path = "docs/tutorial/first-run.md", .text = tutorial_first_run_md },
.{ .path = "docs/how-to/back-up-and-restore.md", .text = howto_back_up_and_restore_md },
.{ .path = "docs/how-to/enable-doh-and-dot.md", .text = howto_enable_doh_and_dot_md },
.{ .path = "docs/how-to/install-with-docker.md", .text = howto_install_with_docker_md },
.{ .path = "docs/how-to/install-with-systemd.md", .text = howto_install_with_systemd_md },
.{ .path = "docs/how-to/measure-performance.md", .text = howto_measure_performance_md },
.{ .path = "docs/how-to/set-up-admin-authentication.md", .text = howto_set_up_admin_authentication_md },
.{ .path = "docs/how-to/troubleshoot.md", .text = howto_troubleshoot_md },
.{ .path = "docs/how-to/upgrade.md", .text = howto_upgrade_md },
};
/// The pages that paste a transcript naming the running binary's version. Each
/// one must print the `<version>` placeholder rather than the version of the
/// day the page was written.
pub const version_transcript_pages: []const Page = &.{
.{ .path = "docs/tutorial/first-run.md", .text = tutorial_first_run_md },
.{ .path = "docs/how-to/install-with-docker.md", .text = howto_install_with_docker_md },
.{ .path = "docs/how-to/install-with-systemd.md", .text = howto_install_with_systemd_md },
.{ .path = "docs/how-to/upgrade.md", .text = howto_upgrade_md },
};
+6 -2
View File
@@ -52,14 +52,18 @@ Without `--out` the export goes to stdout, where the file mode is your
redirect's problem:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data | head -6
nxdns export --data-dir /tmp/nxdns-lab/data | head -10
```
```
// nxdns configuration
// generated by `nxdns export` — the database is the source of truth
.{
.upstream = .{ .read_timeout_ms = 3000, .total_timeout_ms = 5000 },
.upstream = .{
.attempt_timeout_ms = 2500,
.read_timeout_ms = 3000,
.total_timeout_ms = 5000,
},
.dns = .{
.bind_ipv4 = "127.0.0.1",
```
+1 -1
View File
@@ -123,7 +123,7 @@ A healthy first start logs the seeding and the bound sockets:
```
info(config_bootstrap): seeded the database from '/etc/nxdns/config.zon'
info(nxdns): nxdns 0.1.0-dev serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
info(web_server): web interface listening on 0.0.0.0:8080
```
+1 -1
View File
@@ -203,7 +203,7 @@ journalctl -u nxdns -f
A healthy start logs a line naming every socket it bound:
```
info(nxdns): nxdns 0.1.0-dev serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
info(nxdns): nxdns <version> serving on udp [::]:53 tcp [::]:53 tcp 0.0.0.0:53; 1 upstream(s); blocklist generation 1
```
nxdns writes to stderr and systemd captures that into the journal; logging
@@ -259,6 +259,22 @@ interface at the very least, and preferably set a password.
wrong, and they would retype a password that can never verify.
- Requests from the box itself skip the API rate limit by default
(`web.api_localhost_exempt`).
- **If you put a reverse proxy in front of the admin interface, configure
`web.trusted_proxies` or turn `web.api_localhost_exempt` off.** A proxy on the
same box connects from loopback, so every request arrives exempt and the API
limiter — the only brake on guessing the admin password — stops applying to
anyone. Listing the proxy's address in `web.trusted_proxies` makes nxdns read
the client's address from the `X-Forwarded-For` the proxy appends, so the
limiter and the SSE connection cap bind each real client again:
```zig
.web = .{
.trusted_proxies = "127.0.0.1",
},
```
The proxy must append its own entry to that header. A proxy that forwards a
client-supplied `X-Forwarded-For` unchanged is not one to trust.
- `web.session_ttl_hours`, `web.api_rate_limit_per_min` and the rest are in the
[configuration reference](../reference/configuration.md); the routes are in
the [API reference](../reference/api.md).
+1 -1
View File
@@ -139,7 +139,7 @@ OK: no problems found
>
> ```
> $ nxdns version
> nxdns 0.1.0-dev (unknown)
> nxdns <version> (unknown)
> zig 0.16.0
> ```
>
+12
View File
@@ -71,6 +71,18 @@ to the capacity is admitted and the long-run rate holds.
up, never zero).
- Loopback addresses (127.0.0.0/8 and ::1) are exempt while
`web.api_localhost_exempt` is true (the default).
- The address a bucket keys on is the socket peer, unless that peer is listed in
`web.trusted_proxies`. For a listed peer the address is instead the **last**
entry of the request's `X-Forwarded-For` — the entry the proxy appended, which
is the only one a client cannot write. A request from a trusted proxy with no
such header keys on the proxy itself; one whose last entry is not an IP
literal is answered 400, because the alternative is granting the proxy's own
loopback exemption to whoever sent it. Only `X-Forwarded-For` is read;
`Forwarded` (RFC 7239) and the PROXY protocol are not.
- Without `web.trusted_proxies`, a same-box reverse proxy makes every request
loopback, so the default exemption disables the limiter for all remote
clients. Set the proxy's address there, or set
`web.api_localhost_exempt = false`.
- Exempt routes, which never consult a bucket: `/metrics` and `/api/health` (a
Prometheus scrape must never see 429) and `/api/queries/live` (one long-lived
stream must not drain its address's bucket; it is bounded by the SSE
+2 -1
View File
@@ -43,7 +43,8 @@ first. What it checks, in order:
bit is set, which does not change the exit code.
4. A live probe: one real A query for `example.com` through every enabled
upstream, driving the same pool and failover machinery the server uses, with
`upstream.total_timeout_ms` as the per-attempt deadline. A FAIL line names
the same two deadlines: `upstream.attempt_timeout_ms` bounds one try against
one upstream, `upstream.total_timeout_ms` the whole probe. A FAIL line names
the upstream and the concrete cause recorded in its health. This probe leaves
the machine, so `check` needs network access to pass. It runs from the
command line but not from unit tests.
+52 -17
View File
@@ -53,8 +53,20 @@ Timeouts for talking to upstream resolvers.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `upstream.attempt_timeout_ms` | u32 | 2500 | ms | 100120000, and not above `total_timeout_ms` | deadline on one attempt against one upstream inside the pool's failover loop (`src/upstream/pool.zig`), the whole attempt including the connect |
| `upstream.read_timeout_ms` | u32 | 3000 | ms | 100120000 | read deadline on conditional-forward-zone exchanges (`src/local/forward_client.zig`) |
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100120000, and not below `read_timeout_ms` | per-query budget of the upstream pool (`src/upstream/pool.zig`), the whole attempt including the connect; also the `nxdns check` probe deadline |
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100120000 | per-query budget of the upstream pool (`src/upstream/pool.zig`): every failover attempt together, not one of them; also the `nxdns check` probe deadline |
The two pool budgets nest. `attempt_timeout_ms` bounds one try against one
upstream; when it expires the pool records the failure and moves to the next
candidate. `total_timeout_ms` bounds the whole loop, so a query against five
unreachable upstreams costs the total budget once, not five attempt budgets in
a row. When the total expires the in-flight attempt is canceled and the query
fails with a timeout.
`read_timeout_ms` is unrelated to both. It bounds a different subsystem — the
conditional-forward-zone client — so no cross-check relates it to the pool's
budgets, and it is free to sit above either of them.
### dns
@@ -86,9 +98,14 @@ What a blocked query gets back.
| Key | Type | Default | Unit | Validation | Consumed by |
|---|---|---|---|---|---|
| `cache.size` | u32 | 10000 | entries | none; 0 disables caching (puts short-circuit) | DNS answer cache capacity (`src/cache/dns_cache.zig`) |
| `cache.size` | u32 | 10000 | entries | 11000000 | DNS answer cache capacity (`src/cache/dns_cache.zig`) |
| `cache.negative_ttl_max` | u32 | 3600 | seconds | at most 86400 | cap on cached negative answers; 0 disables negative caching |
The cache's slot array is allocated in full at startup, so `cache.size` carries
a ceiling: it is a sanity bound against a typo, not a promise that the value
fits in the box's memory. There is no "off" value — to run without a cache, set
`cache.size` to 1.
### web
The web interface and REST API.
@@ -104,6 +121,7 @@ The web interface and REST API.
| `web.api_rate_limit_per_min` | u32 | 300 | requests per minute | at least 1 | API token-bucket limiter (`src/web/api_limiter.zig`) |
| `web.api_localhost_exempt` | bool | true | — | — | loopback requests skip the API limiter |
| `web.sse_max_connections_per_ip` | u16 | 3 | connections | at least 1 | cap on concurrent SSE streams per client address |
| `web.trusted_proxies` | string | `""` | — | comma-separated IP literals | reverse proxies whose `X-Forwarded-For` nxdns believes (`src/web/server.zig`); empty trusts none |
### doh_server
@@ -145,7 +163,7 @@ Process log and query log behavior.
|---|---|---|---|---|---|
| `logging.level` | enum `.err` \| `.warn` \| `.info` \| `.debug` | `.info` | — | one of the four tags; stored as `"error"` / `"warn"` / `"info"` / `"debug"` | log threshold (`src/platform/logging.zig`) |
| `logging.retention_days` | u16 | 30 | days | at least 1 | query-log pruning cutoff (`src/storage/retention.zig`) and the client tracker's last-seen cutoff (`src/server/clients.zig`) |
| `logging.query_log_buffer_max` | u32 | 10000 | entries | at least 1 | in-memory query-log ring size and backpressure cap (`src/storage/logger.zig`) |
| `logging.query_log_buffer_max` | u32 | 10000 | entries | 11000000 | in-memory query-log ring size and backpressure cap (`src/storage/logger.zig`) |
| `logging.hide_domains` | bool | false | — | — | the query log stores a hidden marker instead of the domain |
| `logging.hide_client_ips` | bool | false | — | — | the query log stores a hidden marker instead of the client address |
| `logging.output` | enum `.stderr` \| `.syslog` \| `.file` | `.stderr` | — | one of the three tags | log sink selection (`src/platform/logging.zig`); `.stderr` and `.syslog` both write to stderr (journald captures it), `.file` rotates |
@@ -197,7 +215,7 @@ Upstream resolvers. At least one enabled upstream is required.
| Field | Type | Default | Validation |
|---|---|---|---|
| `url` | string | required | an `https://` (DoH) or `tls://` (DoT) endpoint accepted by `transport.Endpoint.parse`; unique |
| `url` | string | required | an `https://` (DoH) or `tls://` (DoT) endpoint accepted by `transport.Endpoint.parse`; unique; a `tls://` host must be an IP literal |
| `priority` | i32 | 100 | — (lower is tried first) |
| `enabled` | bool | true | — |
| `tls_name` | string | `""` | DoT only — a `tls_name` on an `https://` upstream is an error; when set it must be a valid domain name |
@@ -207,6 +225,19 @@ ascending priority and tried in order with failover. `tls_name` sets SNI and the
certificate verification name for a `tls://` upstream written as an IP literal;
empty means "verify by the URL host" (`src/upstream/dot_client.zig`).
A `tls://` upstream's host must be an IP literal. nxdns does not resolve an
upstream's own name — that is a bootstrap problem, and the DoT client refuses a
non-literal host on every dial — so the validator rejects the hostname form
rather than letting it fail at query time as an unreachable upstream. Write the
address and put the name in `tls_name`:
```zig
.{ .url = "tls://9.9.9.9:853", .tls_name = "dns.quad9.net" },
```
A DoH upstream is not affected: `https://` goes through the HTTP client, which
resolves normally, so a hostname there is the usual form.
### clients
Known clients with a fixed group assignment.
@@ -344,18 +375,19 @@ path segment is the whole account identifier. The field path beside the message
names the entry, so `blocklist_sources[1].url` still says which one to go and
fix.
One credential this cannot remove: a NextDNS **DoT** upstream is
`tls://abcd12.dns.nextdns.io`, which carries the same identifier in the hostname.
Stripping it would leave no host at all and no line worth reading. So if you use
NextDNS over DoT, your profile id appears in the log.
What redaction cannot remove is the host, because a hostname is not a secret in
the general case — it is resolved publicly and offered as SNI on every
connection — and dropping it would leave a diagnostic that names nothing worth
reading. A vendor that puts an account identifier in the hostname therefore has
that identifier appear in any line naming the entry. NextDNS is the example:
`abcd12.dns.nextdns.io` is a profile id.
The host is kept because a hostname is not a secret in the general case — it is
resolved publicly and offered as SNI on every connection — and dropping it would
cost every operator a diagnostic to cover one vendor's choice. If that trade is
wrong for you, it is yours to make rather than ours: NextDNS also publishes a DoH
endpoint, `https://dns.nextdns.io/abcd12`, whose identifier sits in the path and
is redacted in full. Configure that form instead and nothing identifying reaches
the log.
Two things limit the exposure. A hostname `tls://` upstream is rejected by the
validator (see `upstreams` above), so the DoT form of that URL can only reach
the log once, in the line rejecting it — never on every failover. And NextDNS
publishes a DoH endpoint, `https://dns.nextdns.io/abcd12`, whose identifier sits
in the path and is redacted in full; configure that form and nothing identifying
reaches the log at all.
The error set is `validate.ValidateError` in `src/config/validate.zig`:
@@ -363,6 +395,7 @@ The error set is `validate.ValidateError` in `src/config/validate.zig`:
| --- | --- |
| `NoUpstreams` | no upstream has `enabled = true` |
| `BadUpstreamUrl` | an upstream `url` is not an `https://` or `tls://` endpoint |
| `UpstreamHostNotIpLiteral` | a `tls://` upstream `url` names a host instead of an IP literal |
| `DuplicateUpstreamUrl` | two upstreams share a `url` |
| `BadTlsName` | a `tls_name` is not a valid domain name |
| `TlsNameOnNonTlsUpstream` | a `tls_name` on a non-`tls://` upstream |
@@ -378,13 +411,15 @@ The error set is `validate.ValidateError` in `src/config/validate.zig`:
| `BadLocalRecordName` / `BadLocalRecordValue` / `DuplicateLocalRecord` | local record fields |
| `BadForwardZone` / `DuplicateForwardZone` / `BadResolverUrl` | forward zone fields |
| `BadPort` | a port field is 0 |
| `BadTimeout` | a timeout is outside 100120000 ms, or `total` is below `read` |
| `BadTimeout` | a timeout is outside 100120000 ms, or `attempt` is above `total` |
| `BadTtl` | `blocking.ttl`, `cache.negative_ttl_max`, a record `ttl`, `web.session_ttl_hours` or `blocklist_update.interval_hours` outside its range |
| `BadRetention` | `logging.retention_days` or `logging.query_log_buffer_max` below 1 |
| `BadCacheSize` | `cache.size` outside 11000000 |
| `BadRetention` | `logging.retention_days` below 1, or `logging.query_log_buffer_max` outside 11000000 |
| `BadLogRotation` | `logging.max_size_mb` or `logging.max_files` below 1 |
| `BadDiskThresholds` | a threshold below 1, or `min_free_mb` above `warn_free_mb` |
| `BadRateLimit` | `dns.rate_limit`, `dns.rate_window_seconds`, `web.api_rate_limit_per_min` or `web.sse_max_connections_per_ip` out of range |
| `BadBindAddress` | a bind field is not an IP address, or not of the required family |
| `BadTrustedProxy` | an element of `web.trusted_proxies` is not an IP literal |
| `MissingCertPath` / `MissingKeyPath` | an enabled TLS listener with an empty path |
| `MissingLogPath` | `logging.output = .file` with an empty or relative `file_path` |
| `PasswordAndHashBothSet` | both `web.password` and `web.password_hash` are set |
+17 -1
View File
@@ -140,9 +140,25 @@ file is also sampled by the disk monitor.
| Path | What it is | Mode |
| --- | --- | --- |
| `logging.file_path` (default `/var/log/nxdns/nxdns.log`) | The live process log. Opened for append; created when it does not exist. | `0666 & ~umask` — nxdns never chmods it |
| `logging.file_path` (default `/var/log/nxdns/nxdns.log`) | The live process log. Opened write-only and written at a tracked offset; created when it does not exist. | `0666 & ~umask` — nxdns never chmods it |
| `<file_path>.1``<file_path>.<max_files - 1>` | Rotated generations, newest first. | Inherited from the live file they were renamed from |
**Do not point external logrotate at this file.** nxdns owns the rotation of
its own log. There is no append mode in Zig 0.16, so the writer reads the file
length once when it opens the file and then writes every line at an offset it
tracks in the process. A rotator that moves the file behind it breaks that
offset, and nxdns does not notice until the next restart:
- With `copytruncate` the offset survives the truncation, so the next line
lands where it would have without it. The file regrows with a sparse,
NUL-filled prefix as long as the log that was just rotated away.
- With rename-and-create nxdns keeps writing to the renamed inode. The new
file stays empty, the renamed one grows without bound, and
`logging.max_size_mb` bounds nothing on disk.
If an external rotator has to own the file, set `logging.output = .stderr` and
let the collector capture the stream instead.
The log file is the one path here nxdns does not set a mode on. `config.db` and
`querylog.db` are chmodded to 0600 whatever the umask; the log file is left at
whatever the umask gives it. Under the shipped unit that is 0600, because
+1 -1
View File
@@ -105,7 +105,7 @@ info(migrations): config.db migrated from schema version 0 to 2
info(config_bootstrap): seeded the database from '/home/you/nxdns-tutorial/config.zon'
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, 199 bytes
info(nxdns): nxdns 0.1.0-dev 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(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
```