docs: unwrap hand-wrapped prose repo-wide
Gates / frontend (push) Successful in 1m2s
Gates / test (push) Successful in 1m38s
Gates / package (push) Successful in 5m5s
Gates / test-aarch64 (push) Successful in 6m30s
Gates / container (push) Successful in 15s
CI / gates (push) Successful in 13m30s

This commit is contained in:
2026-08-15 16:27:36 +02:00
parent 50b8fd5c61
commit 5b3d1cd65c
48 changed files with 2691 additions and 11699 deletions
+32 -106
View File
@@ -1,13 +1,8 @@
# Set up admin authentication
The admin interface and its API are protected by a single operator password.
With no password set, every route is open to anything that can reach the web
port. Set one.
The admin interface and its API are protected by a single operator password. With no password set, every route is open to anything that can reach the web port. Set one.
The commands below run against the scratch lab from
[enable DoH and DoT](enable-doh-and-dot.md): data directory
`/tmp/nxdns-lab/data`, web listener on `127.0.0.1:8451`. On a real install the
data directory is `/var/lib/nxdns` and the web port is 8080.
The commands below run against the scratch lab from [enable DoH and DoT](enable-doh-and-dot.md): data directory `/tmp/nxdns-lab/data`, web listener on `127.0.0.1:8451`. On a real install the data directory is `/var/lib/nxdns` and the web port is 8080.
## 1. Set the password
@@ -21,17 +16,14 @@ Put it in the configuration file, under `web`:
}
```
The plaintext is hashed with argon2id into `web.password_hash` and discarded. It
becomes no database row and appears in no log line. Setting both `password` and
`password_hash` in one file is refused:
The plaintext is hashed with argon2id into `web.password_hash` and discarded. It becomes no database row and appears in no log line. Setting both `password` and `password_hash` in one file is refused:
```
web.password: password and password_hash are both set; ambiguity in a security setting is refused
import failed: PasswordAndHashBothSet
```
Applying that file — with `nxdns import`, or with a `nxdns run --config` start —
announces the change:
Applying that file — with `nxdns import`, or with a `nxdns run --config` start — announces the change:
```
web authentication is now enabled
@@ -39,9 +31,7 @@ web authentication is now enabled
### Absent, empty, and set are three different things
The two fields are optional, and the difference between leaving one out and
setting it to `""` is the difference between keeping your password and removing
it:
The two fields are optional, and the difference between leaving one out and setting it to `""` is the difference between keeping your password and removing it:
| The file says | Effect on the stored password |
| --- | --- |
@@ -51,11 +41,7 @@ it:
| `.password_hash = "$argon2id$…"` | Installs that hash, for example from an export. |
| `.password_hash = ""` | **Removes the password.** Authentication is off. |
Absence has to mean "keep", because the alternative is a foot-gun with a live
round in it. An export carries the full PHC string, which is long and ugly, and
sooner or later someone trims that line out of a file before committing it —
meaning "leave the password alone". If absence meant "no password", that edit
would open the admin interface to the whole LAN without a word.
Absence has to mean "keep", because the alternative is a foot-gun with a live round in it. An export carries the full PHC string, which is long and ugly, and sooner or later someone trims that line out of a file before committing it — meaning "leave the password alone". If absence meant "no password", that edit would open the admin interface to the whole LAN without a word.
So removing the password takes the explicit empty string:
@@ -63,23 +49,17 @@ So removing the password takes the explicit empty string:
web authentication is now disabled
```
And an empty plaintext is refused outright, because hashing the empty string
would switch authentication *on* while making every login impossible — the login
handler rejects empty passwords:
And an empty plaintext is refused outright, because hashing the empty string would switch authentication *on* while making every login impossible — the login handler rejects empty passwords:
```
FAIL web.password: password is set to the empty string; omit the field to keep the stored password, or set password_hash = "" to disable authentication
```
Which of steps 4 and 5 applies to your server depends on its authority. Under
`nxdns run --config FILE` the file is the password: edit it and restart, and the
API refuses the change with a 403. Under bare `nxdns run` the database holds it,
and step 4 or step 5 is how it moves.
Which of steps 4 and 5 applies to your server depends on its authority. Under `nxdns run --config FILE` the file is the password: edit it and restart, and the API refuses the change with a 403. Under bare `nxdns run` the database holds it, and step 4 or step 5 is how it moves.
## 2. Log in
Login is `POST /api/auth/login` with a JSON body. Without a session, the API
answers 401:
Login is `POST /api/auth/login` with a JSON body. Without a session, the API answers 401:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8451/api/stats
@@ -102,16 +82,13 @@ curl -sS -c /tmp/nxdns-lab/cookies.txt \
{"authenticated":true,"auth_required":true}
```
The session token comes back in a `Set-Cookie` header, not in the body. In the
jar it looks like this (value redacted here):
The session token comes back in a `Set-Cookie` header, not in the body. In the jar it looks like this (value redacted here):
```
#HttpOnly_127.0.0.1 FALSE / FALSE 1786559938 nxdns_session <redacted>
```
The cookie is named `nxdns_session` and carries `HttpOnly; SameSite=Lax;
Path=/`. Its `Max-Age` comes from `web.session_ttl_hours`. Send it back on every
later call:
The cookie is named `nxdns_session` and carries `HttpOnly; SameSite=Lax; Path=/`. Its `Max-Age` comes from `web.session_ttl_hours`. Send it back on every later call:
```sh
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w '%{http_code}\n' \
@@ -122,8 +99,7 @@ curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w '%{http_code}\n' \
200
```
A wrong password and an unknown one are the same answer, so a guess learns
nothing:
A wrong password and an unknown one are the same answer, so a guess learns nothing:
```sh
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
@@ -142,9 +118,7 @@ info(web_auth): web login accepted for 127.0.0.1:34040
warning(web_auth): web login refused for 127.0.0.1:59670
```
Sessions live in memory only. A restart logs everyone out. Thirty-two
concurrent sessions are kept; a thirty-third login evicts the least recently
used one.
Sessions live in memory only. A restart logs everyone out. Thirty-two concurrent sessions are kept; a thirty-third login evicts the least recently used one.
## 3. Log out
@@ -160,18 +134,13 @@ curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w 'stats: %{http_code}\n' \
stats: 401
```
Logging out with a stale cookie, or with none, answers the same way. The point
of logging out is to end up logged out, and that is where such a request
already is.
Logging out with a stale cookie, or with none, answers the same way. The point of logging out is to end up logged out, and that is where such a request already is.
## 4. Change the password on a running server
This is a database-mode procedure. In file mode `PUT /api/settings` answers 403
naming the file; edit `web.password` there and restart instead.
This is a database-mode procedure. In file mode `PUT /api/settings` answers 403 naming the file; edit `web.password` there and restart instead.
Send the new one to `PUT /api/settings` as `web.password`. The response is the
full settings document; `password` is write-only and `password_hash` is neither
readable nor directly writable, so neither value comes back.
Send the new one to `PUT /api/settings` as `web.password`. The response is the full settings document; `password` is write-only and `password_hash` is neither readable nor directly writable, so neither value comes back.
```sh
curl -sS -c /tmp/nxdns-lab/c2.txt -X POST http://127.0.0.1:8451/api/auth/login \
@@ -181,8 +150,7 @@ curl -sS -b /tmp/nxdns-lab/c2.txt -X PUT http://127.0.0.1:8451/api/settings \
-d '{"web":{"password":"a-new-password"}}'
```
Changing the password ends every session, including the one that made the
change:
Changing the password ends every session, including the one that made the change:
```sh
curl -sS -b /tmp/nxdns-lab/c2.txt -o /dev/null -w 'old session: %{http_code}\n' \
@@ -205,39 +173,26 @@ Log back in with the new password. That is the whole rotation.
## 5. Change the password without the API
If you have lost the password, the admin interface cannot help — go through the
database instead. Export, edit, import. `nxdns export` always writes
`.password = null` and carries the hash, so an exported file re-imports without
anyone knowing the password. To install a new one, put it in `.password` and
clear `.password_hash`:
If you have lost the password, the admin interface cannot help — go through the database instead. Export, edit, import. `nxdns export` always writes `.password = null` and carries the hash, so an exported file re-imports without anyone knowing the password. To install a new one, put it in `.password` and clear `.password_hash`:
```sh
nxdns export --data-dir /tmp/nxdns-lab/data --out /tmp/nxdns-lab/rekeyed.zon
```
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon`: set `.password` to the
new value and **delete the `.password_hash` line entirely**, so the `web` block
carries one password field and not two:
Edit the `web` section of `/tmp/nxdns-lab/rekeyed.zon`: set `.password` to the new value and **delete the `.password_hash` line entirely**, so the `web` block carries one password field and not two:
```zon
.password = "offline-password",
```
Deleting the line is the part to get right. Setting `.password_hash = ""`
alongside a plaintext password does not clear the way for it — an empty string
is a present value meaning "no password", so the file then states two
contradictory things and is refused:
Deleting the line is the part to get right. Setting `.password_hash = ""` alongside a plaintext password does not clear the way for it — an empty string is a present value meaning "no password", so the file then states two contradictory things and is refused:
```
FAIL web.password: password and password_hash are both set; ambiguity in a security setting is refused
import failed: PasswordAndHashBothSet
```
Stop the server before importing. `import` rewrites the stored hash underneath a
process that read it at startup; a running server keeps verifying against the
old one, so skipping the stop leaves the new password not working until the next
restart. In the lab the server is a foreground `nxdns run`, so Ctrl-C in its
terminal stops it, and it goes back up with the same command:
Stop the server before importing. `import` rewrites the stored hash underneath a process that read it at startup; a running server keeps verifying against the old one, so skipping the stop leaves the new password not working until the next restart. In the lab the server is a foreground `nxdns run`, so Ctrl-C in its terminal stops it, and it goes back up with the same command:
```sh
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
@@ -249,14 +204,9 @@ nxdns run --data-dir /tmp/nxdns-lab/data
imported /tmp/nxdns-lab/rekeyed.zon
```
No flag is needed: replacing a password edits a settings value and deletes no
rows.
No flag is needed: replacing a password edits a settings value and deletes no rows.
On a real install the stop and start are `systemctl stop nxdns` and
`systemctl start nxdns` around the same `import` — **not verified on this
host**, which has no installed nxdns systemd unit (`systemctl status nxdns`
answers `Unit nxdns.service could not be found.`) and where `systemctl` needs
root. See [back up and restore](back-up-and-restore.md).
On a real install the stop and start are `systemctl stop nxdns` and `systemctl start nxdns` around the same `import`**not verified on this host**, which has no installed nxdns systemd unit (`systemctl status nxdns` answers `Unit nxdns.service could not be found.`) and where `systemctl` needs root. See [back up and restore](back-up-and-restore.md).
Once it is back up the old password is refused and the new one works:
@@ -288,14 +238,11 @@ nxdns export --data-dir /tmp/nxdns-lab/data | grep password
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$xqzK66LgiWGvyCmCl6ZRa3GHH0nS5qZnRgVfWmeGadc$1mafhflKFIg3vcHjJaDMAXGQiOjtym2UADZsPW1xkfw",
```
See [back up and restore](back-up-and-restore.md) for when `import` does need
`--allow-delete`.
See [back up and restore](back-up-and-restore.md) for when `import` does need `--allow-delete`.
## What happens with no password set
Authentication is off. Every route is open, and a login attempt succeeds
without minting anything — there is nothing to log in to, and a session that
authorises nothing would be a lie for the browser to store:
Authentication is off. Every route is open, and a login attempt succeeds without minting anything — there is nothing to log in to, and a session that authorises nothing would be a lie for the browser to store:
```sh
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8453/api/stats
@@ -308,24 +255,13 @@ curl -sS -X POST http://127.0.0.1:8453/api/auth/login \
{"authenticated":true,"auth_required":false}
```
`auth_required: false` is how the admin interface knows to stop showing a login
form. Treat this as a lab-only state: bind the web listener to a trusted
interface at the very least, and preferably set a password.
`auth_required: false` is how the admin interface knows to stop showing a login form. Treat this as a lab-only state: bind the web listener to a trusted interface at the very least, and preferably set a password.
## Notes
- A stored hash this build cannot parse is a 500, not a 401. Answering 401 would
tell an operator with a corrupted `web.password_hash` that their password is
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:
- A stored hash this build cannot parse is a 500, not a 401. Answering 401 would tell an operator with a corrupted `web.password_hash` that their password is 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 = .{
@@ -333,17 +269,7 @@ interface at the very least, and preferably set a password.
},
```
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).
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).
Every command on this page was executed on this host as written, against the
lab described at the top, except the `systemctl` stop and start named in step 5
and marked **not verified on this host** there. That includes the whole of
steps 2 to 5, re-run for this revision: the login, logout and rate-limit
transcripts reproduced exactly as printed, the both-set refusal in step 5 was
reproduced by leaving `.password_hash = ""` in the file, and the rekey then
succeeded once that line was deleted. The cookie jar's expiry timestamp is the
one that run produced and will differ on yours.
Every command on this page was executed on this host as written, against the lab described at the top, except the `systemctl` stop and start named in step 5 and marked **not verified on this host** there. That includes the whole of steps 2 to 5, re-run for this revision: the login, logout and rate-limit transcripts reproduced exactly as printed, the both-set refusal in step 5 was reproduced by leaving `.password_hash = ""` in the file, and the rekey then succeeded once that line was deleted. The cookie jar's expiry timestamp is the one that run produced and will differ on yours.