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
276 lines
12 KiB
Markdown
276 lines
12 KiB
Markdown
# 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 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" },
|
|
}
|
|
```
|
|
|
|
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:
|
|
|
|
```
|
|
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 file says | Effect on the stored password |
|
|
| --- | --- |
|
|
| Neither field | Nothing. It stays exactly as it was. |
|
|
| `.password = "…"` | Installs that password. Unchanged plaintext keeps the existing hash rather than re-hashing it. |
|
|
| `.password = ""` | Refused. |
|
|
| `.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.
|
|
|
|
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:
|
|
|
|
```
|
|
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.
|
|
|
|
## 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 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:
|
|
|
|
```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
|
|
|
|
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.
|
|
|
|
```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 = 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:
|
|
|
|
```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:
|
|
|
|
```
|
|
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:
|
|
|
|
```sh
|
|
# Ctrl-C the `nxdns run` terminal, or `kill` its pid from another shell
|
|
nxdns import /tmp/nxdns-lab/rekeyed.zon --data-dir /tmp/nxdns-lab/data
|
|
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.
|
|
|
|
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":"lab-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 a null `password` again:
|
|
|
|
```sh
|
|
nxdns export --data-dir /tmp/nxdns-lab/data | grep password
|
|
```
|
|
|
|
```
|
|
.password = null,
|
|
.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`.
|
|
|
|
## 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`).
|
|
- **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).
|
|
|
|
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.
|