A runtime-safe config PUT rebuilt the effective config from config_base +
the FULL persisted overlay and stored it, which live-applied any
restart-required section persisted by an earlier PUT (features/relay/
premium) while the boot-built clients still ran the old value — and
cleared restart_pending in the process. The exact desync RuntimeClass
exists to prevent.
Now the hot-swap applies ONLY the (all-runtime-safe) patch onto the
currently-running config, and restart_pending is computed honestly via a
shared helper (GET and PUT agree). Also reclassify registration.
require_payment as restart-required: it gates the boot-built payment
client (None unless require_payment||premium at boot), so hot-enabling it
would 500 every registration until restart. Regression tests for both.
The repo pins channel=stable (no rustfmt version), so CI now pulls
rustfmt 1.9.0, which reformats code last touched by an older rustfmt.
main itself failed `cargo fmt --check` under the current toolchain;
this is a formatting-only sweep across 24 files to make the fmt gate
green. No behavior change; tests + clippy -D warnings still pass.
- admin.rs: make the config/invite JSON response structs pub (they are
returned from pub handlers; matches the already-pub AdminKeyView etc.)
- invite_codes/operator_settings: drop redundant reborrow (&mut *tx -> &mut tx)
- electrum: minimize a boolean (De Morgan, behavior-identical)
- relay: move the test module to end of file (items_after_test_module)
Pure lint hygiene surfaced by the current stable toolchain; no behavior
change. cargo clippy --all-targets -- -D warnings is now clean.
Route-level test (tower oneshot, no DB) confirms GET /admin returns the
console HTML with text/html, and GET /admin/assets/<hash>.js returns the
built bundle with a JS content-type. Pairs with the embed test so both
'not built' and 'not wired' regressions fail CI.
Preact + Vite standalone admin-ui bundled into the server binary via
rust-embed. Login is NIP-07 (Smirk wallet signs the NIP-98 admin
challenge); tabs cover instance status, admin keys, invite codes, and
the effective-config editor (live vs restart-required per field).
admin-ui/dist is committed because rust-embed reads it at compile time;
node_modules is gitignored (npm ci reproduces it). A lib test asserts
index.html and the /admin/ asset base are embedded so an unbuilt
console fails the build instead of an operator.
The console is served on the admin plane only (loopback bind), behind
the existing NIP-98 admin_guard + MAC allowlist.
- console section: restart_apply_mode (manual | auto), an operator setting (runtime-safe,
DB-overridable via /admin/config), env RESTART_APPLY_MODE default manual.
- Graceful shutdown: main serve now awaits SIGTERM/Ctrl-C OR an in-app shutdown signal
(AppState.shutdown Notify) via with_graceful_shutdown, draining in-flight requests.
- PUT /admin/config in `auto` mode self-restarts shortly after responding to apply a
restart-required change (systemd Restart=always brings it back with the new config);
`manual` just flags restart_pending. The console warns before an auto save.
Stage 3 complete: operator config is readable + editable through the real validated,
audited, precedence-based layer; runtime-safe edits apply live, infra edits restart.
cargo all-targets clean, lib tests pass.
The operator-editable config surface, on the effective-config layer:
- GET /admin/config: per-field effective value + the DB overlay (source=db) + per-section
version + runtime_class map + restart_pending (persisted-but-unapplied change).
- PUT /admin/config: a sparse patch is field-merged onto the persisted overlay, validated
with the SAME Config::validate boot uses (400 on invalid, nothing persists), then each
changed section is persisted audited + version-guarded (409 on a stale expected_version).
Runtime-safe edits HOT-SWAP the effective config atomically (arc-swap, live next request);
any restart-required field is persisted + flagged restart_pending (operator restarts to
apply). Records the editing admin key as updated_by.
- config_overlay: editable_overlay() (effective values as an overlay) + EDITABLE_FIELDS.
- operator_settings: optimistic-concurrency (expected_version) + settings_versions().
Secrets remain unreachable (overlays carry no secret fields). cargo all-targets clean,
lib 256/0. Next (3b): restart_apply_mode (manual|auto self-restart) + graceful shutdown.
Wire the settings overlay into the live config path:
- AppState.config: Config -> Arc<ArcSwap<Config>> (hot-swappable effective config) +
config_base: Arc<Config> (immutable env-only). New AppState::cfg() accessor; every
config read goes through it (state.cfg().field), so a runtime-safe operator edit is
seen on the next request. One Config type, no parallel path.
- Boot (main.rs): load_settings_overlay -> Config::apply_overlay(validate) -> build
every client/worker/router from the EFFECTIVE config. A persisted overlay that no
longer validates is ignored (boot env-only + loud log), never bricks. Env-only when
ADMIN_KEY_INTEGRITY_SECRET is unset.
- Migrated ~59 state.config.X reads to state.cfg().X across api/*; bound guards where a
reference outlives the statement; integrity_secret returns owned String.
- Test harness builds AppState with ArcSwap + config_base (arc-swap dev-dep).
Behavior-preserving: with no operator_settings rows, effective == env config.
cargo check --all-targets clean; lib tests 256/0.
9 confirmed findings (review wbdf3c0sd), all fixed:
S4 (capabilities):
- REGRESSION: /capabilities reported grin_relay:false for a working relay (S4
wrongly coupled it to Grin chain serviceability). The relay is a non-custodial
mailbox — wallet broadcasts locally — so it is decoupled again. Removed the now-
dead grin_relay downgrade note.
S3 (bootstrap latch):
- A DELETE of the latch row escaped Locked without Tampered detection on a
CLI-bootstrapped-but-no-users instance (adoption keyed off `users` only). Boot
now adopts on has_any_users() OR has_any_admin_keys(), so a deleted latch
re-latches Locked instead of silently dropping to uninitialized.
- bootstrap_admin's single-bootstrap guard was a check-then-insert race (two
concurrent `setup` could seed two admins). It now takes the admin-keys mutation
advisory lock (shared with add/revoke/rotate) at the top of its tx.
S2 (admin posture + landing):
- Hard bind guard matched loopback by string prefix ("localhost".starts_with) —
"localhost.evil.com" slipped past. Now resolves via to_socket_addrs and requires
EVERY resolved addr to be loopback.
- host_allowed's IPv6 loopback check was an unanchored prefix ("[::1]" matched
"[::1].evil"); now anchored to exactly "[::1]" / "[::1]:port". +regression tests.
- Anti-clickjacking headers (X-Frame-Options/CSP) are now applied on the 403
Host-reject path too (computed on both branches, headers added once).
- GET /api/v1/server-info now returns a BARE 404 when landing is off (the
{error,code} envelope itself hinted the route exists).
- Public landing `chains` now uses the SAME serviceability predicate as
/capabilities (no over-advertising an unserviceable chain).
- Removed the dead/misleading PUBLIC_EXPOSE_FEATURES toggle (never read; the
feature tuple is intentionally never emitted anonymously).
Dismissed after verification: latch chains-by-flag (dup), title length bound
(operator-owned + escaped), prices:true with empty PRICES_ASSETS (documented).
140 lib + 140 integration green, no openapi drift, scans clean.
Make /capabilities fail-closed and add the admin features surface.
- Secret-presence downgrade (§4.2): a chain whose flag is on but whose source is
unconfigured (btc/ltc no electrum URL, xmr/wow no LWS admin key, grin no owner
secret) now reports enabled:false — so /capabilities never advertises a chain
that would 404, and a missing-secret downgrade is indistinguishable from a
deliberately-off chain. grin_relay requires serviceable Grin; nostr_identity
requires PUBLIC_API_URL. effective_capabilities() is the single explicit mapper.
- contract_version stamp on the public document (clients soft-notice a higher
value, ignore unknown additive keys).
- GET /admin/features (admin_guard): the resolved effective state PLUS admin-only
downgrade reasons (the public surface hides the reason, per §4.3).
- Golden test: the public capabilities JSON, built from a fully-secret-seeded
config, contains no secret value (asserts no "integration-test-" substring).
openapi regenerated (contract_version). Admin features view stays off the public
OpenAPI.
Deferred (documented): runtime mutation — PUT /admin/features + a DB feature-
override table + resolve() precedence + Arc hot-swap + CLI `features set`. Flags
are env/config-driven for v1; runtime tuning (and its fleet propagation, itself a
deferred LB decision) is a later addition.
Admin posture (§2.1/§2.2):
- Hard fail-closed bind guard in main: a non-loopback ADMIN_BIND refuses startup
unless ADMIN_ALLOW_PUBLIC_BIND=true (was a warning).
- admin_plane_guard middleware on the admin router: Host allowlist
(localhost/127.0.0.1/[::1]/configured TOR_ADMIN_ONION; a missing Host is allowed
since rebinding must supply one and the bind is the primary control) to defeat
DNS-rebinding against the loopback socket, plus X-Frame-Options: DENY +
CSP frame-ancestors 'none' (anti-clickjacking). Still no CORS on the admin plane.
Public landing (§2.3), default-OFF, one fingerprint surface:
- GET /api/v1/server-info — built from a hand-written ServerInfo allowlist (never
by serializing config/state). Default projection is minimal { software.name,
status }; version (coarse major.minor), chains, price_feed, and uptime (bool
`up` only) are each individually opt-in. The enabled-feature tuple is NEVER
emitted anonymously (connected clients use /capabilities).
- GET / — minimal HTML (operator title HTML-escaped); bare 404 when off (does not
hint an admin plane exists). server-info also 404s when off.
- config: ADMIN_ALLOW_PUBLIC_BIND, TOR_ADMIN_ONION; PUBLIC_LANDING_TITLE +
per-field PUBLIC_EXPOSE_VERSION/CHAINS/PRICE_FEED toggles.
Tests: host_allowlist + html_escape unit tests; landing off->on minimal-projection
(asserts only software+status, no version/chains/features); admin-plane Host-403 +
anti-clickjacking headers. Landing/admin stay off the public OpenAPI.
Deferred (documented): §2.5 NIP-05 consent-gating (changes existing
/.well-known/nostr.json behavior — needs the standing v0.2.4-compat verification
first), §2.3 k-anon public stats, response-header/TLS-fingerprint stripping (proxy
/ops-doc concern).
From a 5-lens adversarial review of the admin surface (6 confirmed findings,
collapsing to 4 fixes; 2 dismissed after verification):
- HIGH: last-key-revoke TOCTOU (lockout). The count(live)<=1 floor was checked in
the handler, then revoke ran separately — two concurrent revokes could both pass
and empty the allowlist. Moved the floor INTO revoke_admin_key_full under a
per-tx advisory lock (admin_keys_mutate_lock); returns RevokeKeyOutcome
{Revoked,NotFound,WouldEmptyAllowlist}.
- LOW: ADMIN_MAX_KEYS cap TOCTOU. Same check-then-mutate race on add. Cap is now
counted INSIDE create_admin_key_audited under the same lock; returns
AddKeyOutcome {Created,CapReached}. rotate takes the lock too, so all
add/revoke/rotate serialize.
- LOW: integrity MAC didn't cover activation_deadline — a DB-write attacker could
null/extend the deadline to revive an expired pending key without breaking the
MAC. Added activation_deadline to AdminKeyMacInput + the canonical input +
every compute/verify site; new KAT binds it.
- LOW: get_active_admin_key (and create_*) logged the full pubkey via #[instrument]
(params weren't skipped), defeating the prefix-only / no-social-graph design.
Skipped pubkey/input from those spans.
Dismissed (both skeptics not-a-bug): audit-chain JSONB `details` round-trip — the
handlers always pass details:None and serde's sorted-key default makes the
string case round-trip (the L1 chain test proves it). Documented v1 deferrals
(login timing-oracle hardening, per-pubkey backoff, hard non-loopback bind guard)
remain tracked, not regressions.
All gates green: 138 lib + 138 integration, no openapi drift, secret-scan, leak.
Admin-plane key management, all admin_guard-protected and fail-closed audited:
- POST /admin/keys: add a pending key (validated 64-char lowercase x-only hex;
created_by_kind=admin; activation_deadline = now + ADMIN_PENDING_KEY_TTL_DAYS).
Capped at ADMIN_MAX_KEYS live keys (anti allowlist-stuffing).
- GET /admin/keys: list active/pending/revoked (sanitized view + status).
- DELETE /admin/keys/🆔 soft-revoke + cascade-revoke its sessions + audit in ONE
tx. Refuses to revoke the LAST live key over the network (CLI-only), so an
operator can't lock everyone out.
- POST /admin/keys/:id/rotate: atomic revoke-old(+sessions)+add-new(pending)+audit
in one tx — permitted on the last key (add+revoke, not bare revoke), the in-band
recovery for a compromised solo key.
db: create_admin_key refactored onto a shared insert_admin_key(&mut PgConnection)
helper; create_admin_key_audited / revoke_admin_key_full / rotate_admin_key run
their state change + session cascade + hash-chained audit in a single transaction.
1 L1 test extended to cover add/list/rotate/revoke + malformed-pubkey rejection.
NOTE: the live-key cap and last-key revoke guard are count-based on a table the
shared test DB pollutes across suites, so their boundaries aren't integration-
asserted (covered by review); the happy path raises the cap.
"Sign in with Smirk" for operators. Auth is a NIP-98 signed action over a
single-use nonce (AUTHN) + the MAC'd allowlist (AUTHZ), composed in admin_guard
so a protected route physically cannot run without both. All rejections collapse
to one opaque 401 (no enumeration oracle).
- src/api/admin.rs: admin_guard (verify access token -> live session by jti ->
live MAC-verified, activated allowlist entry) + AdminContext; handlers:
- challenge: mint a 120s admin_login nonce + return the {url, instance_id} the
wallet must bind.
- verify: verify_signed_action (purpose/nonce/descriptor/instance) THEN consume
the nonce (forged sig can't burn a victim's nonce; replay loses the race) ->
authorize (pending key allowed for first login, which activates it) -> mint +
persist session AND the login audit row in ONE tx (fail-closed) -> bump
last_used.
- refresh: re-run FULL authorization before minting (closes the "8h refresh is
the real blast radius" gap) + constant-time refresh-hash bind + rotate jti so
the old access token dies.
- logout: revoke the session; audit. me: guarded whoami.
- admin_router (lib): served on a SEPARATE loopback socket in main (confidentiality
by socket, not middleware); no CORS layer (cross-origin browser calls blocked);
warns if bound non-loopback (hard bind-guard lands with admin posture). Routes
are intentionally absent from the public OpenAPI.
- config: ADMIN_PUBLIC_URL (the signed-action u-tag base; validated when enabled).
AppState.admin_sessions (Some only when enabled). AdminSessionManager.mint_access
(refresh) + db helpers (create_admin_session_audited, touch_admin_key_last_used).
- test harness: admin_router() + request_on(router); 1 L1 test exercising the full
flow end-to-end incl. user-token rejection, replay rejection, jti rotation, and
post-logout revocation.
NOTE (for the upcoming S1 review): admin-login timing-oracle hardening (fixed-cost
allowlist probe + jittered 401 floor, spec §1.3) is deferred — the loopback/onion
exposure + single-use nonce + signature gate are the v1 boundary. Per-pubkey
backoff (§0.5) also pending.