milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
This commit is contained in:
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -139,7 +139,7 @@ OK: no problems found
|
||||
>
|
||||
> ```
|
||||
> $ nxdns version
|
||||
> nxdns 0.1.0-dev (unknown)
|
||||
> nxdns <version> (unknown)
|
||||
> zig 0.16.0
|
||||
> ```
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -53,8 +53,20 @@ Timeouts for talking to upstream resolvers.
|
||||
|
||||
| Key | Type | Default | Unit | Validation | Consumed by |
|
||||
|---|---|---|---|---|---|
|
||||
| `upstream.attempt_timeout_ms` | u32 | 2500 | ms | 100–120000, 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 | 100–120000 | read deadline on conditional-forward-zone exchanges (`src/local/forward_client.zig`) |
|
||||
| `upstream.total_timeout_ms` | u32 | 5000 | ms | 100–120000, 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 | 100–120000 | 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 | 1–1000000 | 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 | 1–1000000 | 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 100–120000 ms, or `total` is below `read` |
|
||||
| `BadTimeout` | a timeout is outside 100–120000 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 1–1000000 |
|
||||
| `BadRetention` | `logging.retention_days` below 1, or `logging.query_log_buffer_max` outside 1–1000000 |
|
||||
| `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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user