Files
nxdns/docs/how-to/set-up-admin-authentication.md
T

8.6 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 importnot 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 has content. 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_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; 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.