Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Web Admin

A browser- and script-facing management interface for the server: the accounts it has registered, the orders it has issued, the EAB credentials it honours, and the nonce table.

It is a second listener, on its own socket, serving no ACME — and it is off by default. A certificate authority should not grow a management surface because somebody upgraded it.

It has two faces over the same operations: HTML pages at /ui for a browser, and a JSON API at /api for a script. Neither is built on the other; both are thin layers over the same crates/admin/src/admin/ operations the CLI calls.

[admin]
enabled = true

There is no sign-up page and never will be. The first operator is created from a shell on the host — see Users & Sessions.

Why a second listener

The ACME listener is public, unauthenticated, and frequently internet-facing. Everything about its defaults follows from that: admission control that sheds load, a filter chain, a small body limit.

The admin listener has the opposite shape. It defaults to loopback, requires a session on every route but sign-in, and deliberately carries no admission control (the availability concern here is credential brute force, which the login rate limiter handles and admission control would not touch). It does not inherit the profiles’ [filter] either: it has a policy of its own, [admin.filter].

Access control on this listener is the bind address, TLS, [admin.filter], and the session.

Putting both on one socket would have meant one set of defaults for two very different threat models.

Exposing it

The default binds loopback only:

[admin]
bind_address = "127.0.0.1:3001"
base_url     = "http://localhost:3001"

The recommended way to reach it from elsewhere is an SSH tunnel, which needs no configuration change at all:

$ ssh -N -L 3001:127.0.0.1:3001 ca.example.com

Then open http://localhost:3001 locally. admin.base_url stays as it is, because from the browser’s point of view the panel really is on localhost.

Binding to a real interface

Startup refuses a non-loopback bind_address while admin.tls.enabled is false:

admin.bind_address `0.0.0.0:3001` is not loopback while admin.tls.enabled is
false: the session cookie is sent `Secure`, which a browser will not store over
plain HTTP on anything but localhost, so signing in would appear to succeed and
then fail silently. Set admin.tls.enabled = true, or bind 127.0.0.1 and reach
it through an SSH tunnel

This is an error rather than a warning on purpose. The session cookie is always sent Secure — making that conditional is how a session cookie leaks — and browsers accept a Secure cookie on http://localhost but silently refuse it on http://192.0.2.10:3001. The operator would see “sign-in works, then I am immediately signed out” with nothing in any log to explain it.

So a public bind means TLS:

[admin]
enabled      = true
bind_address = "0.0.0.0:3001"
base_url     = "https://admin.example.com:3001"

[admin.tls]
enabled = true

As with [server.tls], a self-signed certificate for the host of admin.base_url is generated on first start when the files are missing, and the key is created 0600. The paths default to admin.pem/admin.key, separate from the ACME listener’s — the two answer to different names and should not share a certificate by accident.

Give the panel its own host name

Every response carries Strict-Transport-Security: max-age=31536000; includeSubDomains. Once a browser has seen that over HTTPS it will refuse plain HTTP for the whole host, and for every name under it, for a year — which is the point when the panel has a host of its own, and a trap when it does not.

The scope is the host in admin.base_url, so base_url = "https://admin.example.com:3001" commits admin.example.com and anything below it, and nothing else. But base_url = "https://example.com:3001" commits every subdomain of example.com, including services that have nothing to do with this server and may not speak HTTPS at all. HSTS is not scoped by port, so running on :3001 does not narrow it.

Give the panel a name of its own. There is no configuration key for the header: weakening it for everyone is the wrong trade when a dedicated host name costs a DNS record.

Nothing to worry about while admin.tls.enabled is false and the bind is loopback — a browser ignores the header entirely over plain HTTP (RFC 6797 §7.2), which is why it is emitted unconditionally rather than gated.

Restricting who can reach it

Where a host firewall can guard the port, use it. Where it cannot — a container runtime owns the host’s netfilter tables, so an nftables rule on the host is not yours to write — [admin.filter] puts the same policy engine the ACME listener uses in front of every admin request, /health included:

[admin]
enabled      = true
bind_address = "0.0.0.0:3001"
base_url     = "https://admin.example.com:3001"

[admin.tls]
enabled = true

[admin.filter]
rules = ["mgmt"]

[admin.filter.check.mgmt-net]
type  = "allowed_ip"
allow = ["10.20.0.0/24", "127.0.0.1/32"]

[admin.filter.rule.mgmt]
when = "mgmt-net"
then = "allow"

A caller outside 10.20.0.0/24 gets 403 before any handler runs: the admin API’s access_denied error under /api, the HTML error page elsewhere. Only checks that read the request itself make sense here — allowed_ip, path, reverse_dns, custom — and startup refuses the others by name. The check and rule syntax is the one Filters documents; the section’s own rules are in the Configuration Reference.

Two things are worth knowing in a container:

  • Container networking rewrites addresses. Published ports usually keep the client’s address, but some setups (rootless Docker’s port forwarder, a userland proxy) make every connection appear to come from the bridge gateway. Check the client_ip on the admin access line before relying on an allowlist.
  • A reverse proxy in front (Traefik, Caddy, nginx) is every peer. List its network in admin.filter.trusted_proxies, and the policy, the access line and the sign-in rate limiter all see the real client from its X-Forwarded-For. The loopback-or-TLS rule above still applies to the bind: a proxy in another container reaches the panel over the network, so keep [admin.tls] on and have the proxy speak HTTPS to it.

A health check run inside the container (Docker’s HEALTHCHECK) arrives from loopback, so allow 127.0.0.1/32 as above. An orchestrator probing from outside needs a type = "path" check for /health and a rule allowing it.

Authentication

Sign-in exchanges a username and password for an opaque session token:

  • The token is 256 random bits, and only its SHA-256 is stored. A database read — a backup, a .dump, an injection — yields nothing replayable.
  • It travels in a __Host-acme_admin_session cookie, HttpOnly; Secure; SameSite=Strict; Path=/. The __Host- prefix is browser-enforced: it requires those attributes, so an edit that drops one breaks visibly.
  • Sessions have both an absolute lifetime (session_ttl_seconds, never extended by activity) and an idle timeout (session_idle_timeout_seconds).
  • Passwords are hashed with PBKDF2-HMAC-SHA256 at 600 000 iterations. A failed sign-in costs the same whether the username exists or not, so the endpoint cannot be used to enumerate operators — and every failure returns the same invalid_credentials, whatever the real cause. The log says which.

CSRF

Every request with an unsafe method must carry an X-CSRF-Token header matching the session’s csrfToken, which sign-in and GET /api/session both return.

SameSite=Strict is set as well, but it is not sufficient on its own here, and the reason is specific: SameSite is scoped to the registrable domain, not the origin — different ports of the same host are same-site. A panel on :3001 beside anything else on :8080 of the same box is exactly what it does not cover.

There is also an origin gate: a request whose Origin does not match admin.base_url, or whose Sec-Fetch-Site says cross-site, is refused. That is what covers sign-in itself, which by definition carries no session token yet. Both headers are checked only when present, so a script or curl is unaffected.

Roles

A session also carries the operator’s role (Users → Roles). A mutating request is refused with 403 insufficient_role unless the role permits it: a CA action (revoke, delete an account or order, EAB, nonce sweep) needs operator or admin; the /operators/* and /ui/operators/* colleague-management routes (including a colleague’s role and notification address) need admin; the own-account routes (password, notification address, own sessions, own second factor, sign-out) need only a live session. An operator whose row predates the feature is admin.

Reads are open to every role with one exception: the /operators surface needs admin to read as well as to act, on both front ends. A tier that gated only the writes would be a control over what a colleague can do and not over what they can learn — and that surface carries every operator’s role and contact address, the addresses each recently signed in from, and every live session’s fingerprint. So GET /api/operators, /api/operators/{username} and /api/operators/{username}/sessions, and the two /ui/operators pages, answer 403 insufficient_role to an operator or a viewer.

SameSite=Strict also means clicking a link into the panel from another site will not carry your session. For an admin panel that is a feature, but it surprises people.

The panel

Open admin.base_url in a browser: / redirects to /ui/, and anything under it that needs a session bounces to /ui/login.

PageWhat is on it
/ui/What needs attention — failed jobs, certificates expiring within seven days, refusals in the last day, and any endpoint bypassing validation — each linking to its list; then totals for accounts, orders, EAB credentials and nonces, and the mounted endpoints
/ui/accountsEvery account, filterable by profile and EAB credential, listed with the address its key was last seen from; a detail page carries both recorded addresses, the contact editor, deactivate and delete
/ui/ordersEvery order, filterable by profile, status, account, identifier (exact or substring) and certificate serial; a detail page shows the authorizations and challenges, offers the issued chain for download, and revokes or deletes
/ui/eabCredentials, paged; minting (the secret is shown once); a detail page counts the accounts it registered, links to them, and revokes or deletes — keeping, deactivating or deleting those accounts
/ui/expiringCertificates lapsing inside a window, soonest first, each annotated with whatever has already replaced it; filterable by profile and window, with a control to hide the replaced ones. Read-only
/ui/jobsThe background queue — relayed issuance, notification delivery, the periodic sweeps — filterable by kind and status. A detail page cross-links a relay job to its upstream order and carries Cancel and Run now (see below)
/ui/upstream-ordersOne row per local order relayed to an upstream CA: the upstream URLs, the upstream’s own error text, the local order status. Read-only — stop an in-flight relay from /ui/jobs instead
/ui/auditThe CA’s audit trail — every issuance and every refusal, filterable, with a detail page per row. Read-only: there is no route here that prunes it
/ui/noncesThe table size, and a manual sweep
/ui/profilesThe endpoints this process serves, and a warning for any that bypass validation
/ui/profiles/{name}/filterOne endpoint’s resolved access policy: every check, and every rule in evaluation order with its condition re-parenthesized. Read-only

The account pages surface the CA’s account-side traceability columns: where newAccount was called from and the reverse name that address had at the time (Created from), and when and where the key last authenticated a request (Last seen, Last seen from). Each is shown only when it was recorded — a reverse lookup that found nothing leaves the address alone, and an estate where it can never succeed leaves both names blank rather than every row saying “unknown”. Nothing in the server ever compares against them; they answer “who asked for this certificate, and from where”, not “may this request proceed”.

How it is built

[htmx], vendored into the binary — no npm, no build step, no CDN. The templates are [minijinja] and can be overridden on disk without rebuilding.

Each list and detail route serves two representations of one URL: a whole document for a normal navigation, and the bare fragment htmx is going to swap when the request carries HX-Request. So /ui/orders?status=valid is a real, bookmarkable URL whether you got there by clicking a filter or by typing it.

The CSRF token reaches the browser as an hx-headers attribute on <body> and comes back as the same X-CSRF-Token header the API uses — the pages needed no second CSRF mechanism, which is the main reason htmx was chosen over plain forms.

Sign-in is the exception: a plain HTML form, no JavaScript, protected by the origin gate rather than a token (there is no session to have one yet). It works with scripting disabled.

The API

Mounted at /api, unversioned. Every response is application/json with Cache-Control: no-store.

A filter left blank is the same as leaving it out: ?profile= is every profile, not a profile whose name is the empty string. That matters because the panel’s own controls are <select> elements inside a submitted form, which always send their name — so every profile and any status reach the API as blanks.

MethodPath
POST/api/sessionsign in — {username, password}
GET/api/sessionwho am I, and my csrfToken
DELETE/api/session[?all=true]sign out (of this browser, or all)
GET/api/session/mfawhat a half-authenticated cookie still owes — {step}
POST/api/session/mfafinish the sign-in — {code}, a TOTP or a recovery code
GET/api/mfa{totpEnabled, enrolmentPending, recoveryCodesRemaining}
POST/api/mfa/totpbegin an enrolment — returns the secret, once
POST/api/mfa/totp/confirm{code} — returns the recovery codes, once
DELETE/api/mfa/totpturn it off; 409 while admin.require_mfa is on
POST/api/mfa/recovery-codesreissue — returns them once
GET/api/accounts?profile=&eabKid=&limit=&offset=eabKid: the accounts one credential registered
GET/api/accounts/{id}
GET/api/accounts/{id}/orders?limit=&offset=
PATCH/api/accounts/{id}{contact: [...]}
POST/api/accounts/{id}/deactivate
DELETE/api/accounts/{id}cascades to the account’s orders; 409 live_certificates while one holds a live certificate
GET/api/orders?profile=&accountId=&status=&identifier=&identifierContains=&certSerial=&limit=&offset=identifier is exact, identifierContains a substring, the two mutually exclusive; certSerial is the issued leaf’s serial
GET/api/orders/{id}order + authorizations + challenges, plus certificatePem once issued
POST/api/orders/{id}/revoke{reason} optional
DELETE/api/orders/{id}409 live_certificates while its certificate is live
GET/api/eab?limit=&offset=never shows a secret
POST/api/eab{label, profile} — returns the secret, once
GET/api/eab/{kid}
POST/api/eab/{kid}/revokethe row survives, moved to revoked
DELETE/api/eab/{kid}?accounts=keep|deactivate|deletethe row goes; 409 live_certificates for delete while an account holds a live certificate
GET/api/expiring?profile=&days=&superseded=&limit=&offset=read-only; superseded=hide drops the replaced rows
GET/api/audit?profile=&accountId=&orderId=&certSerial=&event=&outcome=&limit=&offset=read-only
GET/api/audit/{id}one row
GET/api/nonces{count, ttlSeconds}, the shape nonce count --json prints
POST/api/nonces/cleanup{ttlSeconds} optional
GET/api/profilesthe endpoints actually mounted; profile list’s document
GET/api/profiles/{name}/filterone endpoint’s resolved access policy; read-only
GET/healthunauthenticated, no database access

Lists return an envelope, not a bare array — total is what the same filters match unpaged, which is what a page control needs:

{ "items": [ … ], "total": 137, "limit": 50, "offset": 0 }

limit defaults to 50 and is clamped to admin.page_size_max rather than refused. Every listing on the CLI answers the same four members (Admin CLI → Paging), so a script learns one shape rather than two.

POST /api/session for an operator with a second factor answers 200 with {"mfaRequired": true, "step": "verify"} and no user member — a half-authenticated session must not read operator metadata. It is not a 401: the password was right, and a script has to be able to tell those apart. Send the code to POST /api/session/mfa, which answers the ordinary session body and a new cookie; the pending one is dead by then.

The two …/session/mfa routes are the only mutating endpoints that do not take X-CSRF-Token. The sign-in page they serve is a plain form with no token to send, exactly as POST /api/session has none, and the origin check covers both.

The issued chain

An order’s detail carries certificatePem, the chain as it was issued, and the order page renders it with a GET /ui/orders/{id}/chain.pem download (application/pem-certificate-chain). The ACME certificate member beside it is a URL, and one a browser cannot follow — RFC 8555 §7.4.2 serves it by signed POST-as-GET only, so it is there for completeness rather than as a link.

certificatePem is on the detail shape only, never on a listing: a page of fifty orders would otherwise carry fifty chains for a column no list shows. An order that never reached issuance has neither the field nor the download, and the route answers 404 rather than an empty file.

On a host holding the database, acme-proxy order chain <id> prints the same bytes on stdout under the same rule — see Admin CLI → Order management.

The card names the leaf two more ways, and both are on the listing shape as well since each is one short string: certSerial, which is what an abuse report quotes and what GET /api/audit?certSerial= filters on, and certNotAfter, the certificate’s own expiry — a different date from the Not after above it, which is the §7.4 window the client requested. Both are omitted on an order that never issued rather than sent empty.

Revocation

POST /api/orders/{id}/revoke resolves the revocation route from the order’s own profile. Two profiles can hold two different CAs, and revoking against the wrong one would record nothing useful and leave the real CRL untouched. An order belonging to a profile this process no longer mounts answers 409 profile_not_mounted rather than guessing.

The panel holds no signing backend — only the worker role does — so it revokes the way the CLI does. A local_ca revocation is recorded at once and the worker signs it into the CRL. A relay or custom revocation is queued for the worker and waited on; if it is still running when the wait ends, the API answers 202 {"status": "queued", "job": "<id>"} and the page says so. Follow it on the Jobs page.

The job queue

/api/jobs and /ui/jobs list the background queue and expose two mutations, both requiring an operator-or-higher session (a viewer is refused):

  • POST /api/jobs/{id}/cancel — retires the job (status = cancelled). A running job is refused with 409 job_not_cancellable; wait out its lease and cancel the resulting ready/failed row. Cancelling a signer_relay_issue job also marks its ACME order invalid and abandons the upstream mapping, with a certificate_issue_failed audit row naming the operator — the way to stop a relayed issuance that will never complete. Cancelling a periodic sweep job stops that sweep until the server restarts.
  • POST /api/jobs/{id}/run — makes the job eligible immediately (picked up within jobs.poll_interval_ms; the runner is not woken). On a ready job it pulls run_at forward; on a failed one it grants exactly one more attempt — attempts is set to max_attempts - 1, not reset, so repeatedly clicking it is the only way to loop a permanently failing job.

On the page, a 409 renders as a banner beside the still-present card; a 5xx replaces the page. The list rows carry no controls — the two buttons live on the detail card, and only when the job is ready or failed.

/api/upstream-orders and /ui/upstream-orders are read-only, like the audit trail below but for the queue’s reason: abandoning an in-flight relay is POST /api/jobs/{id}/cancel, not a route here. They list one row per relayed local order — the upstream URLs, the upstream CA’s own error text, the finalize request’s request_id — joined to the local order for its identifiers and status, and never carry the stored CSR. Both contribute no entry to the CSRF test table.

The audit trail is read-only here

/api/audit and /ui/audit list, filter, page and resolve a row by id, and that is all they do. There is no route on this listener that deletes audit history, and that is a deliberate limit rather than a missing feature: the first thing a stolen session would do is erase what it had just done, and a trail the watched thing can erase proves nothing. Pruning happens on the host with acme-proxy audit cleanup, or on a schedule via audit.retention_days.

This is also why /api/audit contributes no entry to the CSRF test table — with no mutating verb, there is nothing to protect. Every other verb on those paths is unroutable. See Audit Trail.

The expiry list is read-only too, for a different reason

/api/expiring and /ui/expiring answer the digest’s question on demand: what lapses soon, and has anything replaced it? They share the query, the ordering and the supersession rule with [notify.expiry] and with order list --expiring-in, so a page and a mail never disagree about what is about to expire.

Neither has a mutating route, and the reason is not the audit trail’s: renewal is the client’s action, driven by its own ACME flow against a key this server does not hold. There is simply nothing here for a button to do. Both therefore contribute no entry to the CSRF test table.

Two members of the answer need reading together. total counts the rows the window matches; hidden counts the ones this page dropped as already replaced. They are separate because supersession is computed per row rather than in SQL, so the count beside the page cannot follow the filter down — and a pager whose arithmetic quietly disagrees with the rows under it would be worse than saying so. The page says it in a line above the table.

The default window is [notify.expiry] lead_days wherever the digest is on, and 30 days where it is off: an operator who has chosen a lead time gets that one back.

The policy is shown, never explained

/api/profiles/{name}/filter and /ui/profiles/{name}/filter answer what acme-proxy filter show prints: the default effect, every check with its type and the stages it decides at, and every rule in evaluation order with its condition re-parenthesized, so an operator who wrote a or b and c reads a or (b and c) back. It is the missing half of the warning on /ui/profiles: an endpoint with challenge.bypass on has [filter] and nothing else between it and its clients, and this is where that policy can be read.

filter explain has no equivalent here and is not getting one. It really runs the policy — it executes the operator’s custom scripts and issues real IPAM and DNS requests, against an address and a list of names the caller chose. Behind a session that is script execution plus SSRF from one stolen cookie, on a listener that deliberately carries no filter chain and no admission control. show is the opposite case: it reads an already-built policy through four accessors, runs no check, and reaches nothing outside the process. Neither surface has a mutating verb — a policy is configuration, and configuration is edited in config.toml and reloaded — so both contribute no entry to the CSRF test table, for the same structural reason /api/audit does not.

One difference from the CLI is worth knowing, because it is the useful kind. The panel reads the live policy, the one this process is enforcing right now; acme-proxy filter show rebuilds one from configuration, which is what makes it the cheapest pre-restart check. [filter] reloads on SIGHUP and the whole policy is swapped, so between an edit and its reload the two legitimately disagree — and a configuration that would be refused is reported by the CLI while the panel goes on serving the last good policy. Both answers are correct; comparing them is how an operator finds out which state they are in.

An endpoint with no rules is a state, not an error: the answer carries "active": false and says the endpoint filters nothing, rather than a 404. That is the one policy an operator most needs to be told about.

Security notifications

Every web-admin sign-in and every second-factor change is logged, and — when [admin.notify] is configured — the operator it happened to is also told (ASVS V6.3.5 / V6.3.7). Two events fire:

  • admin_sign_in — a completed sign-in from an address not among the operator’s recent ones (admin_users.known_login_ips keeps the last five distinct addresses; a first-ever sign-in has no baseline and is silent), a correct password followed by a refused second factor, or a per-session second-factor lockout.
  • admin_credential_changed — the operator’s password changed, a second factor was enrolled or removed, recovery codes were regenerated, or another administrator reset this operator’s second factor (by_self = false). Also when the operator’s notification address changed (change = "contact_address") — and that one message goes to the address that was replaced, not the new one, since whoever made the change controls the new address. A first-ever address replaced nothing and tells nobody.

[admin.notify] has exactly the shape of the per-profile [notify] section — enabled, email / webhook / custom backends, template_dir, per-backend events — but is process-wide and built only while [admin] is enabled (ACME_PROXY_ADMIN__NOTIFY__ENABLED). Email delivery goes to each operator’s own address rather than to notify.email.to, stored in admin_users.contact_email. An operator sets their own from Your account (it asks for the current password), an admin sets a colleague’s from the Operators page, and the host sets anybody’s with acme-proxy admin user create --contact <address> or admin user contact <username> --contact <address>. See Users → Notification address. An operator with no address on file gets no message (the event is still logged); [admin.notify.email].to, if set, is the fallback for that case.

Changes made from the host CLI (admin user passwd, contact, totp reset, totp recovery-codes) notify too. The CLI queues the delivery and exits, and the running server’s job runner sends it, so a change made while no server runs is delivered when one starts. Nothing is queued while admin.enabled is off, since there is then no [admin.notify] to deliver through.

Errors

Not ACME problem documents. Every Problem type in this server is a hardcoded urn:ietf:params:acme:error:* URN, and nothing on this listener is an ACME error:

{ "error": "not_found", "message": "no such account: acct-1" }

error is a stable snake_case code you may branch on; message is for a human and may change. The codes:

  • Request: bad_request, invalid_status, invalid_contact, conflicting_identifier_filter, not_found, method_not_allowed.
  • Session and access: session_invalid, session_expired, session_idle, invalid_credentials, csrf_failed, rate_limited, access_denied, insufficient_role, mfa_required, mfa_not_enabled.
  • The row’s state: order_not_issued, already_revoked, live_certificates, last_admin, job_not_cancellable, job_not_runnable, profile_not_mounted.
  • Server: signer_failed, internal.

The pages answer the same failures as HTML carrying the same code, with one deliberate split: a refusal that is about the row’s state — 409 already_revoked, order_not_issued — comes back as a banner beside the button you pressed, with the record still on screen, while a server problem replaces the page. A missing session is neither: a browser gets 303 to /ui/login, and an htmx request gets the HX-Redirect header, because a 303 is followed by fetch before htmx ever sees it and the sign-in page would be swapped into whatever you clicked.

Driving it with curl

$ curl -sc jar -X POST http://127.0.0.1:3001/api/session \
       -H 'content-type: application/json' \
       -d '{"username":"alice","password":"…"}'
{"csrfToken":"…","expiresAt":"…","user":{…}}

$ curl -sb jar 'http://127.0.0.1:3001/api/orders?limit=5'

$ curl -sb jar -X POST http://127.0.0.1:3001/api/eab \
       -H "x-csrf-token: $CSRF" -H 'content-type: application/json' \
       -d '{"label":"team-a"}'

Security notes

  • This is a second attack surface on a certificate authority. It is off by default, binds loopback, and needs a session — but it has no admission control, and filters nothing until [admin.filter] names a rule. That is worth knowing rather than discovering.
  • Sign-in is protected by a fixed-window rate limiter (admin.login_max_attempts per admin.login_window_seconds, keyed on the client address — an IPv6 client by its /64, which one subscriber can rotate through at will). An attempt counts from the moment it starts, so a parallel burst gets no more guesses than a sequence. Over the limit, the password hash is not computed at all — 600 000 iterations is a denial-of-service lever otherwise — and the hash that does run is on the blocking pool, off the workers that serve requests.
  • A forwarded-for header is believed only from admin.filter.trusted_proxies, never from the ACME listener’s filter.trusted_proxies; honouring it from anyone else would let a caller spoof the key the rate limiter counts on. Behind a reverse proxy that is not listed there, the limiter counts the proxy.
  • Every response carries Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'. No unsafe-inline, no unsafe-eval — affordable because htmx is served from this origin and drives everything through hx-* attributes rather than inline handlers. Alongside it: no-store, nosniff, X-Frame-Options: DENY, Referrer-Policy: same-origin and HSTS.
  • htmx.min.js is a vendored third-party file, and cargo deny audits the crate graph and cannot see it. Its version, source URL, SHA-256 and licence are recorded in crates/admin/src/webadmin/static/README.md, which is the only provenance record there is — check it when you update.
  • A second factor (TOTP) is available per operator, and admin.require_mfa makes it compulsory — see Operators and sessions. It is off by default, so the loopback bind plus an SSH tunnel remains the baseline posture and not a substitute for one.
  • WebAuthn is not implemented. webauthn-rs 0.5 hard-depends on openssl/openssl-sys and is MPL-2.0, neither of which this tree carries; the design does not preclude it later (another factor is another branch in the same state machine, not a change to it).

Configuration

See the Configuration Reference for every [admin], [admin.filter] and [admin.tls] key, and Customizing the Panel for admin.template_dir.

[htmx]: https://htmx.org [minijinja]: https://docs.rs/minijinja