Files
nxdns/docs/how-to/set-up-admin-authentication.md
mokhtar c50c6d285a
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 4m55s
CI / frontend (push) Successful in 39s
CI / cross (push) Successful in 7m57s
CI / docker (push) Failing after 1h10m42s
milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
2026-08-07 17:55:59 +02:00

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 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 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_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:

    .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; 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.