9.4 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 seed 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" },
}
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:
curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8451/api/stats
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 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:
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:
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 '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.
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/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:
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:
.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:
# 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.
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":"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:
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 holds configuration. See
back up and restore.
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/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_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, except the
systemctl stop and start named in step 5 and marked not verified on this
host there.