Skip to content

Security

  • A second factor, if you want one. The account dialog can require a six-digit code from an authenticator app at every sign-in — standard TOTP, which any app produces. Enrolment only completes once the phone proves it holds the secret, so an unscanned QR code can never lock the account, and eight single-use recovery codes are shown once at setup. The pushed administrator record carries the second factor too, so a failover node asks for the same code. Lost phone, no recovery codes: app.py disable-2fa on the node's own shell — deliberately physical, because whoever can run that already owns the machine. The QR code is drawn by a small vendored encoder, verified module-for-module against a reference implementation; no dependency was added.
  • Sign in with a username and password. The installer creates the administrator and prints the generated password (also written to /var/lib/haproxy-manager/admin-credentials.txt, mode 0600); change it from the account dialog, behind the gear beside your name. Passwords are stored only as a PBKDF2-SHA256 hash, the session is an HMAC-signed HttpOnly / SameSite=Strict cookie that expires after 12 hours, and repeated failures lock that address out briefly. The login is node-local — set it on each node.
  • The API key (Cluster → This node) is for machines, not people: the peer must present it before it may push configuration here, and scripts can send it as X-API-Key instead of signing in.
  • If no administrator exists yet, the UI asks you to create one on first visit. Until then only the calls that create it answer — everything else returns 401 — so a node waiting to be set up does not hand its configuration to whoever reaches it first.
  • Every API endpoint requires a session or the API key. Of 87 routes exactly three answer without either: /api/login, /api/whoami (which unauthenticated returns nothing but whether an administrator exists), and /api/setup, which refuses once an administrator exists. This is verified by a test that walks every route and checks the rest refuse an anonymous caller. On a node with no administrator yet, /api/setup/state answers too, so the browser can tell it must offer the setup wizard; it returns 401 the moment an administrator exists. The sign-in page and its icons are served without a session too, because the page has to render before anyone can sign in; they come from a fixed list of filenames, not from the directory. The /.ham-sso/ routes for single sign-on are public by design — they exist to authenticate strangers — but they answer only on the configured sign-in host, act only on HMAC-signed parameters, and HAProxy routes nothing else to them.
  • The administrator login is node-local, but it can be copied. Each node stores its own; changing the password on one leaves the others as they were, which is a problem you tend to discover during a failover. The account dialog therefore offers Apply to the other nodes whenever there are any. What travels is the stored PBKDF2 salt and digest, over the peer channel, authenticated with the receiving node's API key — never the password, and never from a browser. If a node does not take it, the dialog says which one and why, and stays open: that node keeps the old login until you fix it.
  • Put the UI behind TLS (or an SSH tunnel / reverse proxy). Over plain HTTP both the password and the session cookie cross the network in the clear.
  • The service runs as root because it writes /etc/haproxy, /etc/keepalived and reloads services. Restrict who can reach port 8080.
  • HAProxy's workers do not run as root. The generated global section sets user haproxy and group haproxy, so the processes that handle traffic drop to the distribution's haproxy user once the ports are bound; only the master process, which spawns a new worker on reload, keeps root. There is no chroot — HAProxy logs through /dev/log, which a chroot would hide — and the configuration says so (chroot /) rather than leaving HAProxy 3.3+ to warn about it on every start. A custom global line overrides any of these.
  • Served by waitress, a production WSGI server — deliberately as a single process with a thread pool. The app keeps state in process globals (the lock that makes configuration writes atomic, the failed-sign-in counters, the renewal timer), so running several worker processes would give each its own copy and let concurrent edits overwrite one another. Raise HAM_THREADS if you need more concurrency; do not put a multi-process server in front of it. If waitress is missing the app still starts, on the development server, and says so in the log.
  • Request bodies are capped at 16 MB, and config.json — which holds the API key, session secret, peer keys and password hash — is written mode 0600.