Admin UI & API Key Management¶
v2.12.0+ — Self-hosters can mint, list, and revoke API keys through a
built-in admin UI rather than editing $api_tokens in config.php. Keys
are bcrypt-hashed in SQLite; the plaintext token is shown exactly once at
creation.
Enabling the admin UI¶
Set three values in config.php:
$admin_ui_enabled = true;
$admin_user = 'admin'; // any non-empty string
$admin_pass_hash = '$2y$10$...'; // see below
Generate the password hash on the server with PHP's CLI:
Paste the $2y$10$… output as $admin_pass_hash. Never commit config.php.
The admin UI is only meant to be reached over TLS. If your install is behind
a reverse proxy, terminate TLS there and ensure /admin/ is not proxied to
plain HTTP.
Using the web UI¶
Navigate to https://your-host/admin/keys.php. The browser will prompt for
the admin username + password (HTTP Basic Auth). Each request re-authenticates
— there is no session cookie. After login you can:
Shared admin chrome (v3.2.0+, #345)¶
Starting in v3.2.0 the admin pages render through the same shared layout as
the calculator. Each admin page (keys.php, audit.php, totp.php,
totp-verify.php, the first-run wizard) shows the calculator's logo,
version pill, and theme toggle in the page header, plus a breadcrumb chip
between the version pill and the theme toggle. The dark/light theme toggle
now works on /admin/ (it previously force-rendered dark because the
admin pages did not load the toggle script).
Each admin page has exactly one outer .card wrapping all sub-sections.
Sub-sections render as <section> blocks separated by a 1px divider — no
nested cards. Inputs share the calculator's --color-input-bg token, and
form layouts reuse the calculator's .form-group / .splitter-btn
components. Destructive actions use a token-driven .btn-danger class.
An interim footer link cluster (API Keys · TOTP / 2FA · Audit Log) appears
at the bottom of every admin page with the active page rendered as a
non-link aria-current="page" text node. The cluster will be replaced by
a left sidebar in v3.3.0 (#344).
Available actions¶
- Mint a key: enter a human-readable name and submit. The token is shown once on the next page; copy it immediately. After page reload it is gone.
- List keys: see name, public prefix (first 8 hex chars), creation time, last-used time, and active/revoked status.
- Revoke a key: click Revoke. Revocation is immediate and permanent.
Post-mint UX (v3.2.0+, #348)¶
After a successful mint, the new-token panel renders an inline <code>
block with two buttons:
- Copy — writes the token to the system clipboard via
navigator.clipboard.writeText()(with adocument.execCommand('copy')fallback for legacy browsers / cross-origin iframes). Flips to "Copied!" for 1.5 seconds on success, then reverts. - Got it — removes the panel from the DOM. The panel is one-shot per mint anyway (the next page load clears the PRG flash), so the dismiss button is purely an explicit "I've copied it" affordance.
The empty state (no keys minted yet) now shows a one-paragraph nudge explaining what keys authenticate against, with links to the API reference and rate-limit headers docs so an operator can find the next step without leaving the admin UI.
Using the JSON API¶
Mirror endpoints under /api/v1/admin/keys accept the same Basic Auth and
respond with the standard JSON envelope. Useful for scripting or CI:
# List
curl -u admin:pass https://host/api/v1/admin/keys
# Mint
curl -u admin:pass -X POST -H 'Content-Type: application/json' \
-d '{"name":"production"}' https://host/api/v1/admin/keys
# Revoke
curl -u admin:pass -X DELETE https://host/api/v1/admin/keys/42
Auth semantics¶
API authentication is governed by what is configured:
$api_tokens |
Active SQLite keys exist | Bearer required? |
|---|---|---|
[] |
no | no — open API |
[] |
yes | yes |
['static-tok'] |
no | yes |
['static-tok'] |
yes | yes |
In other words: minting your first SQLite key flips the API from open to
auth-required. Revoking the last active SQLite key flips it back to open
(unless $api_tokens is non-empty). To keep the API permanently locked
regardless of mint/revoke state, set at least one entry in $api_tokens.
Static $api_tokens and SQLite-stored keys are checked in that order; both
auth methods coexist. Static tokens still rotate by editing config.php.
Token format¶
Minted tokens look like:
The first 8 hex characters become the public prefix used to identify the key in the UI without storing the secret. The remaining 24 hex chars are known only to the holder; the database stores a bcrypt hash of the full token.
Storage¶
The api_keys table is created in:
$apikey_db_pathif set, otherwise$session_db_pathif set, otherwise<docroot>/../data/sessions.sqlite(alongsiderate_limit).
Schema:
CREATE TABLE api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
prefix TEXT NOT NULL,
token_hash TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked_at INTEGER
);
CREATE INDEX idx_api_keys_prefix ON api_keys(prefix);
token_hash is the only secret column. Even with read access to the DB,
an attacker would need to crack the bcrypt hash to recover the token.
Security notes¶
- TLS is your responsibility. HTTP Basic transmits the password on
every admin request — never expose
/admin/over plain HTTP. - Bcrypt cost is the default 10 rounds. Each admin auth check ~100ms on a modern CPU; resilient to brute force at the rate-limit ceiling.
- No session cookies. Admin re-authenticates every request, so there is no session to steal.
- CSRF protection on the web UI. The mint/revoke forms include a per-request token derived from the admin password hash + client IP; attackers without the admin credentials cannot construct a valid token.
- Rate limiting still applies. The same
$api_rate_limit_rpmceiling guards/api/v1/admin/*endpoints. - Audit trail.
last_used_atupdates on every successful API call, so you can spot keys that haven't been used in a while and revoke them.
Per-key rate limits (v3.0.0+, #312)¶
Each SQLite-stored API key may carry an rate_limit_rpm override that takes
precedence over the global $api_rate_limit_rpm ceiling. Set it in the
RPM column on /admin/keys.php:
- blank → key inherits the global default.
0→ unlimited for this key.- any positive integer → that RPM ceiling for this key.
The lookup order in api_rate_limit() is: static $api_rate_limit_tokens
override → SQLite per-key override → global $api_rate_limit_rpm. Keys
bucket by row id (key:N) so two tokens that share a prefix (a 1-in-4-billion
collision) cannot interfere with each other's rate-limit window.
There is no upper bound on the SQLite override — operators who configure themselves into a denial-of-service can revoke and re-mint at any time.
JSON API:
# Mint a key with a 600 RPM ceiling
curl -u admin:pass -X POST -H 'Content-Type: application/json' \
-d '{"name":"hot-svc","rate_limit_rpm":600}' \
https://host/api/v1/admin/keys
# Update an existing key's override; null clears it back to the global default.
# Note: 0 means "unlimited" (no rate-limit cap), NOT "clear" — use null for that.
curl -u admin:pass -X PATCH -H 'Content-Type: application/json' \
-d '{"rate_limit_rpm":null}' \
https://host/api/v1/admin/keys/42/rate-limit
TOTP / 2FA management (v3.2.0+, #347)¶
/admin/totp.php is the operator's TOTP configuration surface. The
login-time verification flow (when totp_pending = 1 on a session row)
is covered separately in admin-login.md. This section
covers the configuration page itself.
The page renders three sub-sections under one outer card:
- Status — always shown.
- Enrol — only when
$admin_totp_secretis empty. - Recovery codes + Disable TOTP — only when TOTP is enabled.
Status card¶
Shows:
- Enabled/disabled badge.
- Last TOTP login — UTC timestamp, wrapped in
<time datetime="…Z">so screen readers and locale-aware browsers can reformat it. Reads fromadmin_state['last_totp_at'], set byadmin/totp-verify.phpon every successful step-up. Displays "never" before the first verify. - Recovery codes: N unused / total — counts derived from
admin_recovery_codes. Includes used rows in the total so the operator can see how many codes have been spent.
Enrol flow¶
When $admin_totp_secret is empty, the page renders an inline enrol
sub-card:
- A fresh secret is generated and cached in the operator's PHP session
(
$_SESSION['totp_enrol_secret']). The secret is never written to disk before verification — abandoning the flow has no on-disk side-effect. - The base32 secret is shown in a copyable inline
<code>block. - A
<details>expander reveals theotpauth://URI for authenticator apps that accept URI paste rather than manual base32 entry. - A verify form (
<input type="text" inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code">) accepts the 6-digit code from the authenticator. On match,admin_totp_enrol_persist()writes$admin_totp_secret = '…'intoconfig-admin.phpvia the merge helper; the page reloads with TOTP enabled.
Audit events:
| Event | When | Notes |
|---|---|---|
totp.enrol.start |
First GET of the page when TOTP is disabled (once per session, not per refresh). | Session flag $_SESSION['totp_enrol_started'] debounces. |
totp.enrol.verify.ok |
Verify form accepted; secret written. | Cached enrol session keys cleared. |
totp.enrol.verify.fail |
Verify form code didn't match. | Page re-renders with the same cached secret. |
Recovery codes¶
Same one-shot mint flow as v3.0.0 — codes are shown exactly once at generation and stored as bcrypt hashes. v3.2.0 adds usage tracking:
The migration is idempotent — admin_recovery_db_init() probes
PRAGMA table_info before running the ALTER, so existing installs
upgrade on the first admin request after deploy.
A <details> expander on the page lists every recovery code row:
- Row id + status (
used/unused). - For used rows:
used_at(UTC, in a<time datetime>element) andused_via_ip(the direct client IP at consumption, or theX-Forwarded-Forfirst hop if$admin_audit_trust_xffis set).
Plaintext codes are unrecoverable by design — the operator gets forensic information ("a recovery code was used at T from IP") without the page ever displaying or storing the original code string.
Helper copy on the page reminds the operator: codes are shown once at generation, cannot be retrieved later, and Regenerate invalidates all previous codes (used or unused).
Disable TOTP¶
Bottom-of-page sub-card. Disabling requires the operator to re-prove control of the second factor:
- Operator types a current TOTP code or unused recovery code into the confirmation input.
- The handler tries
admin_totp_verify($admin_totp_secret, $code)first; on mismatch, falls back toadmin_recovery_verify_and_consume($db, $code)(which marks the code used). - Either path success →
admin_totp_disable($db)clears$admin_totp_secretfromconfig-admin.phpANDDELETE FROM admin_recovery_codes. Cached enrol session state is also cleared so a subsequent re-enrol mints a fresh secret.
| Event | When |
|---|---|
totp.disable |
Valid second-factor accepted; secret + codes cleared. |
totp.disable.fail |
Empty / invalid / rate-limited submission. |
admin_state k/v table¶
A new table introduced for last_totp_at and any future single-row
state values (created idempotently by admin_recovery_db_init()):
CREATE TABLE admin_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
Helpers admin_state_get() / admin_state_set() are the only callers.
Operator notes¶
- Disable wipes recovery codes. After re-enrolling, click Mint recovery codes to generate a fresh set — without them, losing your authenticator again means no fallback.
- Hand-edits to
$admin_totp_secretinconfig.phpalways win over whatever the wizard wrote toconfig-admin.php(the v3.0.0 convention). Disable cannot reach intoconfig.php; if the secret is set there, the page surfaces the disable form but the merge helper writes a no-op clear toconfig-admin.phpand the operator's hand-set value still applies. - The enrol session secret never touches disk before verification. If the operator closes the tab mid-enrol, the cached secret expires with the PHP session — no cleanup required.
Settings page (v3.2.0+, #349)¶
/admin/settings.php surfaces ~23 of the ~35 operator-tunable knobs in
config.php across six visible sections plus a collapsed Advanced (CSP)
<details>. Schema-driven validators reject bad input before any disk write;
saves only modify config-admin.php (the wizard tier introduced in v3.0.0).
What it surfaces¶
| Section | Knobs |
|---|---|
| Branding | page_title, page_description, canonical_url, default_tab, locale, fixed_bg_color, show_share_bar, frame_ancestors |
| Forms / Captcha | form_protection, turnstile_*, recaptcha_score_threshold |
| API limits | api_rate_limit_rpm, api_cors_origins |
| Sessions | session_enabled, session_ttl_days |
| Admin & audit | admin_audit_retention_days, admin_audit_trust_xff, admin_audit_purge_strategy, admin_audit_purge_sample_rate |
| Limits | split_max_subnets, lookup_max_cidrs, lookup_max_ips |
| Advanced (CSP) | csp_connect_extra, csp_script_extra, csp_img_extra |
Not surfaced: admin auth (admin_user / admin_pass_hash /
admin_totp_secret — purpose-built pages exist at /admin/login.php and
/admin/totp.php), filesystem path knobs (operator-only), the legacy
api_tokens array (replaced by /admin/keys.php).
Source-of-truth indication¶
Each row carries a small source badge:
default— neither tier sets the key; the schema default applies.admin— written by the Settings UI toconfig-admin.php.config— hand-edited inconfig.php. Always wins over the wizard tier.
When a key is set in BOTH files, the row also carries a red shadowed
tag. Saving a shadowed value still writes to config-admin.php — the UI
value takes effect the moment the operator removes the hand-edit from
config.php.
Secret handling¶
Schema entries marked secret: true (currently the captcha secret keys)
render as ••••••• (set) or (empty) with an aria-label for screen
readers. The existing value is never echoed back into the page, so the
DOM has no source for the original token. The form input is
<input type="password" autocomplete="new-password">; an empty submission
means "no change". The audit log row redacts secret values to (set) or
(empty) in the meta JSON.
Atomic write¶
Saves are validated per-key against the schema. Any validation failure
blocks the entire section's save and pins inline error messages under the
bad fields. On success, config-admin.php is rewritten via the merge
helper introduced in v3.2.0 (#347) — single-line $key = …; lines are
replaced in place; new keys are appended. After write, the file is
re-included to confirm round-trip parsability and opcache_invalidate()
is called so the next request sees the new values.
A pre-flight writability check disables every Save button (and renders a
banner) when config-admin.php (or its parent directory) is not
writable by the web user.
Audit events¶
| Event | Trigger |
|---|---|
config.update |
One row per actual key change. meta carries key, before, after. |
config.reset |
When a key is restored to its schema default. |
Both events render with the badge-private (amber, mutation) treatment
in the audit-log viewer via the v3.2.0 (#346) action-to-badge mapping.
Secrets are redacted to (set) / (empty) in the meta JSON.
Operator notes¶
- The schema in
Subnet-Calculator/includes/functions-admin-settings.phpis the authoritative knob list. Adding a new knob requires a schema entry; novel validation rules (e.g. CIDR list) require extendingsettings_validate(). - The Settings page never touches
config.php. Operators who want to override the wizard tier should hand-editconfig.php; the Settings UI will then display that key with the redconfigbadge plus theshadowedtag and explain the override. - Saves are per-section. Changing knobs across multiple sections requires one Save click per section — by design, so a validation failure in one section can't roll back another.
Audit log (v3.0.0+, #306)¶
Every admin auth attempt and every key.mint / key.revoke / key.rate_limit
action writes a structured row to the admin_audit table. The log is
viewable at /admin/audit.php with a paginated newest-first table and a
prefix filter (login., key., wizard. reserved for v3 PR2, totp.
reserved for v3 PR2).
Schema:
CREATE TABLE admin_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
actor TEXT, -- admin username or NULL on failed auth
ip TEXT, -- client IP from REMOTE_ADDR / X-Forwarded-For
action TEXT NOT NULL, -- 'login.ok', 'login.fail', 'key.mint', 'key.revoke', 'key.rate_limit'
target_id INTEGER, -- e.g. api_keys.id for mint/revoke/rate_limit
meta TEXT -- JSON blob with action-specific detail
);
Retention is bounded by $admin_audit_retention_days (default 90; set to
0 to keep everything until manually rotated).
The audit module fails open: a write failure logs to error_log but never
masks the underlying admin action.
Viewer polish (v3.2.0+, #346)¶
The /admin/audit.php viewer was refreshed in v3.2.0. Schema, retention,
and purge behaviour are unchanged — only the rendered page differs:
- Filter strip is a real
<div role="tablist">with<button role="tab" aria-selected="true|false" aria-controls="audit-rows">per filter (all,login,key,wizard,totp). Behaviour is identical to the v3.0.0 link-based strip; screen readers now announce the active filter. - Action cells render as colour-coded badges via the shared helper
audit_action_badge_class($action)infunctions-audit.php. Suffix matching wins over prefix matching, soauth.login.failis red, not blue:
| Action pattern | Badge | Colour |
|---|---|---|
*.ok, *.success |
badge-public |
green |
*.fail, *.deny, *.error |
badge-multicast |
red |
*.create, *.delete, *.write, *.update, *.revoke |
badge-private |
amber |
wizard.*, totp.*, config.*, auth.* |
badge-doc |
blue |
| (default) | badge-other |
slate |
- Sticky
<thead>keeps column headers visible while scrolling long logs. - Timestamps are wrapped in
<time datetime="2026-05-05T01:44:32Z">…</time>so screen readers and locale-aware browsers can reformat the value. - Pagination disabled side is a non-focusable
<span aria-disabled="true">witharia-label="Previous page (unavailable)"rather than a faded<a tabindex="-1">— keyboard users can no longer focus a "next" link that does nothing. <caption class="sr-only">describes the table to screen readers.
The mapping helper is reusable from any future page that renders audit
rows — keep new audit consumers consistent by calling
audit_action_badge_class() rather than re-implementing the suffix table.
Purge strategy (v3.1.0+, #325)¶
Two settings control when old rows are deleted:
$admin_audit_purge_strategy— one of'inline','sampled'(default), or'cron'.$admin_audit_purge_sample_rate— float in[0.0, 1.0]; only consulted when the strategy is'sampled'. Default0.001(≈1 write in 1000 triggers a purge).
| Strategy | When the purge runs | When to use it |
|---|---|---|
inline |
Every call to audit_log(). |
Low audit volume (< a few writes/min); the v3.0.0 default. |
sampled (default) |
Probabilistic: each write rolls a die against $admin_audit_purge_sample_rate. |
The general-purpose default. At the 0.001 default, busy bursts (mass key rotation, login.fail floods) pay the DELETE cost roughly once per thousand writes instead of every write. |
cron |
Never inline. The operator runs the CLI script on a schedule. | High-volume deployments where the operator wants deterministic purge timing and zero per-write overhead. |
The CLI script bin/sc-audit-purge.php is shipped with the release tarball.
It is not web-served (the directory's .htaccess denies all HTTP
access; the CLI also refuses to run outside the cli SAPI). Run it from
cron — adjust the path to your install:
# Daily at 03:00, log to a file so a non-zero exit triggers cron mail.
0 3 * * * php /opt/subnet-calculator/bin/sc-audit-purge.php >> /var/log/sc-audit-purge.log 2>&1
Output is one line of the form
sc-audit-purge: <before> → <after> rows (-<deleted>) [db=…, retention=… days].
The script exits 0 on success and non-zero on any failure so the cron
daemon mails you on errors.
Switching strategies requires no schema or data migration — change the
config value and the next request honours it. cron mode does not
disable the table or the in-app audit viewer; only the inline purge.
Test-only drain endpoint (v3.1.0+, #324)¶
The admin/_test-drain.php endpoint exists only in the docker test rig.
It zeroes api_keys, admin_audit, and admin_recovery_codes and clears
sc_admin PHP-session files so the Playwright suite can run repeatedly
against a non-fresh webapp container without leftover rows tripping
later assertions.
The endpoint is hard-gated by the PHPUNIT_TEST_DRAIN_TOKEN environment
variable:
getenv('PHPUNIT_TEST_DRAIN_TOKEN')empty / unset → endpoint returns404and reveals nothing. This is the production case.- Token present, request
tokenmismatched →403. - Token matched (timing-safe
hash_equals()) → tables truncated, response{"ok":true,"drained":[…]}.
The token is generated per-run by make test-docker and passed to both
the webapp and playwright-tests containers via docker-compose env. It
is never baked into the image and never committed to source.
The release-tarball build step (see CLAUDE.md in the repository root) excludes
admin/_test-drain.php so the file never ships to operators. If you copy
the file to a production host by accident, it stays inert because the
production environment does not set PHPUNIT_TEST_DRAIN_TOKEN.
Threat model note: anyone with Docker socket access on the test host
can read PHPUNIT_TEST_DRAIN_TOKEN from docker inspect <container>.
This is acceptable because the token is regenerated per make test-docker
invocation and grants no production capability — the production webapp
never has the env var set, so even an exfiltrated token is useless against
the live deployment.
Out of scope¶
The following are intentionally not in v3.0.0 and are deferred to v3.1.0:
- TOTP recovery codes beyond the 10 generated at TOTP enable (e.g. download as printable PDF, automatic regeneration warnings).
- Per-actor TOTP secrets (the v3 implementation is single-secret because
there is currently a single
$admin_user). - Webhook / syslog integration for the audit log.
- Multi-admin user support (separate row per admin in a future
admin_userstable).