feat(admin,landing): admin-plane hardening + public landing [operator §2]

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).
This commit is contained in:
jwinterm
2026-06-27 15:32:11 -04:00
parent 67faf8f762
commit 51a3371be1
8 changed files with 410 additions and 7 deletions
+26
View File
@@ -271,12 +271,26 @@ pub struct AdminConfig {
pub pubkeys: Vec<String>,
pub max_keys: u32,
pub pending_key_ttl_days: u32,
/// Allow the admin plane to bind a non-loopback address (default false →
/// startup refuses a public bind; confidentiality is by socket).
pub allow_public_bind: bool,
/// Tor onion host the admin plane is reached at (added to the Host allowlist;
/// never logged or surfaced publicly).
pub onion: Option<String>,
}
/// Public landing page. Off by default; full + per-field tunable when enabled.
#[derive(Clone)]
pub struct LandingConfig {
pub enabled: bool,
/// Operator free text (HTML-escaped on render). `None` = omitted.
pub title: Option<String>,
/// Emit a coarse (major.minor) version. Default off (version is recon).
pub expose_version: bool,
/// Emit the enabled chain symbols. Default off.
pub expose_chains: bool,
/// Emit `price_feed.enabled`. Default off.
pub expose_price_feed: bool,
pub expose_features: bool,
pub expose_uptime: bool,
pub stats_enabled: bool,
@@ -420,9 +434,15 @@ impl Config {
pubkeys: env_list("ADMIN_PUBKEYS"),
max_keys: env_parse("ADMIN_MAX_KEYS", 8u32)?,
pending_key_ttl_days: env_parse("ADMIN_PENDING_KEY_TTL_DAYS", 7u32)?,
allow_public_bind: env_bool("ADMIN_ALLOW_PUBLIC_BIND", false),
onion: env_opt("TOR_ADMIN_ONION"),
},
landing: LandingConfig {
enabled: env_bool("PUBLIC_LANDING_ENABLED", false),
title: env_opt("PUBLIC_LANDING_TITLE"),
expose_version: env_bool("PUBLIC_EXPOSE_VERSION", false),
expose_chains: env_bool("PUBLIC_EXPOSE_CHAINS", false),
expose_price_feed: env_bool("PUBLIC_EXPOSE_PRICE_FEED", false),
expose_features: env_bool("PUBLIC_EXPOSE_FEATURES", false),
expose_uptime: env_bool("PUBLIC_EXPOSE_UPTIME", false),
stats_enabled: env_bool("PUBLIC_STATS_ENABLED", false),
@@ -677,9 +697,15 @@ mod tests {
pubkeys: vec![],
max_keys: 8,
pending_key_ttl_days: 7,
allow_public_bind: false,
onion: None,
},
landing: LandingConfig {
enabled: false,
title: None,
expose_version: false,
expose_chains: false,
expose_price_feed: false,
expose_features: false,
expose_uptime: false,
stats_enabled: false,