milestone 13: restructure docs to diataxis, tutorial, every command executed
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
# 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 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
|
||||
|
||||
Put it in the seed configuration file, under `web`:
|
||||
|
||||
```zon
|
||||
.{
|
||||
.groups = .{.{ .name = "default" }},
|
||||
.upstreams = .{.{ .url = "https://cloudflare-dns.com/dns-query" }},
|
||||
.web = .{ .bind = "127.0.0.1", .port = 8451, .password = "lab-password" },
|
||||
}
|
||||
```
|
||||
|
||||
At import time 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
|
||||
```
|
||||
|
||||
The seed file is read only while the database is empty. On a server that
|
||||
already has a database, use step 4 or step 5 instead.
|
||||
|
||||
## 2. Log in
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
```
|
||||
401
|
||||
```
|
||||
|
||||
Log in and keep the cookie:
|
||||
|
||||
```sh
|
||||
curl -sS -c /tmp/nxdns-lab/cookies.txt \
|
||||
-X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"password":"lab-password"}'
|
||||
```
|
||||
|
||||
```json
|
||||
{"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):
|
||||
|
||||
```
|
||||
#HttpOnly_127.0.0.1 FALSE / FALSE 1785770178 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:
|
||||
|
||||
```sh
|
||||
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w '%{http_code}\n' \
|
||||
http://127.0.0.1:8451/api/stats
|
||||
```
|
||||
|
||||
```
|
||||
200
|
||||
```
|
||||
|
||||
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 \
|
||||
-H 'content-type: application/json' -d '{"password":"wrong"}' \
|
||||
-w ' (http %{http_code})\n'
|
||||
```
|
||||
|
||||
```
|
||||
{"error":"invalid password"} (http 401)
|
||||
```
|
||||
|
||||
The server logs the address and the outcome, never the password:
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
## 3. Log out
|
||||
|
||||
```sh
|
||||
curl -sS -b /tmp/nxdns-lab/cookies.txt -c /tmp/nxdns-lab/cookies.txt \
|
||||
-X POST http://127.0.0.1:8451/api/auth/logout
|
||||
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w 'stats: %{http_code}\n' \
|
||||
http://127.0.0.1:8451/api/stats
|
||||
```
|
||||
|
||||
```
|
||||
{"authenticated":false}
|
||||
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.
|
||||
|
||||
## 4. Change the password on a running server
|
||||
|
||||
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 \
|
||||
-H 'content-type: application/json' -d '{"password":"lab-password"}'
|
||||
curl -sS -b /tmp/nxdns-lab/c2.txt -X PUT http://127.0.0.1:8451/api/settings \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"web":{"password":"a-new-password"}}'
|
||||
```
|
||||
|
||||
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' \
|
||||
http://127.0.0.1:8451/api/stats
|
||||
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"lab-password"}' \
|
||||
-w ' (old password)\n'
|
||||
curl -sS -c /tmp/nxdns-lab/c3.txt -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"a-new-password"}' \
|
||||
-w ' (new password)\n'
|
||||
```
|
||||
|
||||
```
|
||||
old session: 401
|
||||
{"error":"invalid password"} (old password)
|
||||
{"authenticated":true,"auth_required":true} (new password)
|
||||
```
|
||||
|
||||
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 = ""` 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` so it reads:
|
||||
|
||||
```zon
|
||||
.password = "offline-password",
|
||||
.password_hash = "",
|
||||
```
|
||||
|
||||
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
|
||||
nxdns import /tmp/nxdns-lab/rekeyed.zon --force --data-dir /tmp/nxdns-lab/data
|
||||
nxdns run --data-dir /tmp/nxdns-lab/data --config /tmp/nxdns-lab/etc/config.zon
|
||||
```
|
||||
|
||||
```
|
||||
imported /tmp/nxdns-lab/rekeyed.zon
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
curl -sS -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"a-new-password"}' \
|
||||
-w ' (old password, http %{http_code})\n'
|
||||
curl -sS -c /tmp/nxdns-lab/c5.txt -X POST http://127.0.0.1:8451/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"offline-password"}' \
|
||||
-w ' (new password, http %{http_code})\n'
|
||||
curl -sS -b /tmp/nxdns-lab/c5.txt -o /dev/null -w 'stats: %{http_code}\n' \
|
||||
http://127.0.0.1:8451/api/stats
|
||||
```
|
||||
|
||||
```
|
||||
{"error":"invalid password"} (old password, http 401)
|
||||
{"authenticated":true,"auth_required":true} (new password, http 200)
|
||||
stats: 200
|
||||
```
|
||||
|
||||
The next export shows the new hash and an empty `password` again:
|
||||
|
||||
```sh
|
||||
nxdns export --data-dir /tmp/nxdns-lab/data | grep password
|
||||
```
|
||||
|
||||
```
|
||||
.password = "",
|
||||
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$kvlRj1tdGul3MlfbvzLncLKWirNpJRJ3howFA9/ysgg$7elW7PPQ3WXHwI4YOmOpZ/1KNEQo7ZDLRJhnYOPMjqw",
|
||||
```
|
||||
|
||||
`--force` is required because the database already has content. See
|
||||
[back up and restore](back-up-and-restore.md).
|
||||
|
||||
## 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:
|
||||
|
||||
```sh
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8453/api/stats
|
||||
curl -sS -X POST http://127.0.0.1:8453/api/auth/login \
|
||||
-H 'content-type: application/json' -d '{"password":"anything"}'
|
||||
```
|
||||
|
||||
```
|
||||
200
|
||||
{"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.
|
||||
|
||||
## 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`).
|
||||
- `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, except the
|
||||
`systemctl` stop and start named in step 5 and marked **not verified on this
|
||||
host** there.
|
||||
Reference in New Issue
Block a user