12 KiB
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: 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:
.{
.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:
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8451/api/overview
401
Log in and keep the cookie:
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"}'
{"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:
curl -sS -b /tmp/nxdns-lab/cookies.txt -o /dev/null -w '%{http_code}\n' \
http://127.0.0.1:8451/api/overview
200
A wrong password and an unknown one are the same answer, so a guess learns nothing:
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
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 'overview: %{http_code}\n' \
http://127.0.0.1:8451/api/overview
{"authenticated":false}
overview: 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.
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:
curl -sS -b /tmp/nxdns-lab/c2.txt -o /dev/null -w 'old session: %{http_code}\n' \
http://127.0.0.1:8451/api/overview
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:
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:
.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:
# 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.
Once it is back up the old password is refused and the new one works:
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 'overview: %{http_code}\n' \
http://127.0.0.1:8451/api/overview
{"error":"invalid password"} (old password, http 401)
{"authenticated":true,"auth_required":true} (new password, http 200)
overview: 200
The next export shows the new hash and a null password again:
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 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:
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8453/api/overview
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_hashthat 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_proxiesor turnweb.api_localhost_exemptoff. 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 inweb.trusted_proxiesmakes nxdns read the client's address from theX-Forwarded-Forthe proxy appends, so the limiter and the SSE connection cap bind each real client again:.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-Forunchanged is not one to trust. -
web.session_ttl_hours,web.api_rate_limit_per_minand the rest are in the configuration reference; the routes are in the API reference.
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.