crates/monetize-admin/src/console.rs
//! **The console** — `monetize-admin ui --bind <addr> --server <addr> --root
//! <data>`, a process of its own (G6): monetize-ui-server's router for the ONE
//! local monetize, behind the edge ([`crate::edge`]), the bundle embedded at
//! build time and unpacked to `<data>/monetize-ui/dist`.
//!
//! | what | from |
//! |---|---|
//! | the passkey relying party, the break-glass bearer | `<data>/monetize-ui/settings.toml` ([`crate::solo::UiSettings`], written by PID 1 from the feed; a change restarts this process) |
//! | the server's bearer (loopback) | `<data>/monetize/token` |
//! | the passkeys and sessions | `<data>/monetize-ui/state.redb` |
//! | a passkey reset | the door's marker `<data>/monetize-ui/passkeys.reset`, looked for every two seconds |
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use monetize_ui_server::app::App;
use monetize_ui_server::auth::{RelyingParty, Webauthn};
use monetize_ui_server::grpc::Monetize;
use monetize_ui_server::sse::EventHub;
use crate::solo::{UiSettings, RESET_REL, UI_STATE_REL};
mod dist {
include!(concat!(env!("OUT_DIR"), "/console_dist.rs"));
}
/// Where the bundle is unpacked, under the console's directory.
pub const DIST_REL: &str = "dist";
/// How often the reset marker is looked for.
const POLL: Duration = Duration::from_secs(2);
/// How often the live feed is polled.
const EVENTS_EVERY: Duration = Duration::from_secs(5);
/// Where the embedded bundle was read from at build time; `None` = not embedded.
pub fn dist_source() -> Option<&'static str> {
dist::DIST_SOURCE
}
/// **Unpack the embedded bundle** to `<dir>/dist` (a file rewritten only when it
/// differs). `None` when this binary carries no bundle.
pub fn unpack(dir: &Path) -> std::io::Result<Option<PathBuf>> {
if dist::FILES.is_empty() {
return Ok(None);
}
let out = dir.join(DIST_REL);
std::fs::create_dir_all(&out)?;
for (rel, bytes) in dist::FILES {
let p = out.join(rel);
if std::fs::read(&p).map(|b| b == *bytes).unwrap_or(false) {
continue;
}
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&p, bytes)?;
}
Ok(Some(out))
}
/// The passkey relying party for `rp_id`: `https://<rp_id>`, shown as monetize.
pub fn relying_party(rp_id: &str) -> RelyingParty {
RelyingParty { rp_id: rp_id.to_string(), origin: format!("https://{rp_id}"), rp_name: "monetize".to_string() }
}
/// **The console's router**: monetize-ui-server's, over the ONE local
/// monetize. Every estate it shows is the fleet's; it dials none.
pub fn router(settings: &UiSettings, monetize: Monetize, webauthn: Arc<Webauthn>, ui_dir: Option<PathBuf>) -> axum::Router {
router_with(settings, monetize, None, webauthn, ui_dir)
}
/// [`router`] with the OPERATOR's client (UW-3): the money fixes go with it.
pub fn router_with(settings: &UiSettings, monetize: Monetize, operator: Option<Monetize>, webauthn: Arc<Webauthn>, ui_dir: Option<PathBuf>) -> axum::Router {
// A32-14, stays a task: the console's event hub, for the console's life.
let app = Arc::new(App { webauthn: Some(webauthn), admin_token: settings.admin_token.clone(), events: EventHub::spawn(monetize.clone(), EVENTS_EVERY), monetize, operator, ui_dir });
monetize_ui_server::serve::router(app)
}
/// ★ UW-3: **the console's client with the OPERATOR bearer** (monetize's
/// `operator-token`, minted by `intake::prepare`): refund and settle go with it, from
/// this console only — the box has no shell. `None` when none is kept.
pub fn operator_client(root: &std::path::Path, server: &str) -> Option<Monetize> {
let token = monetize_front::intake::operator_token(root)?;
match Monetize::connect_lazy(server, &token) {
Ok(m) => Some(m),
Err(e) => {
tracing::error!(error = %e, "console: the operator's client cannot be made; refund and settle are refused");
None
}
}
}
/// The words of `monetize-admin ui …`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UiArgs {
pub bind: SocketAddr,
pub server: SocketAddr,
pub root: PathBuf,
}
/// Read `--bind`, `--server` and `--root`; all three are required.
pub fn parse_args(args: &[String]) -> Result<UiArgs, String> {
let (mut bind, mut server, mut root) = (None, None, None);
let mut it = args.iter();
while let Some(w) = it.next() {
let mut value = || it.next().cloned().ok_or_else(|| format!("{w} needs a value"));
match w.as_str() {
"--bind" => bind = Some(value()?.parse().map_err(|e| format!("--bind: {e}"))?),
"--server" => server = Some(value()?.parse().map_err(|e| format!("--server: {e}"))?),
"--root" => root = Some(PathBuf::from(value()?)),
other => return Err(format!("unknown argument {other:?}")),
}
}
Ok(UiArgs { bind: bind.ok_or("--bind is required")?, server: server.ok_or("--server is required")?, root: root.ok_or("--root is required")? })
}
/// **Serve the console** until the process ends: refused by name when it
/// cannot (PID 1 starts it again).
pub async fn serve(args: UiArgs) -> Result<(), String> {
let root = &args.root;
let settings = UiSettings::read(root).ok_or_else(|| format!("REFUSED [console-settings] no readable {}", root.join(crate::solo::UI_SETTINGS_REL).display()))?;
let token = std::fs::read_to_string(root.join(monetize_front::intake::TOKEN_REL)).map_err(|e| format!("REFUSED [console-token] {}: {e}", root.join(monetize_front::intake::TOKEN_REL).display()))?;
let monetize = Monetize::connect_lazy(&args.server.to_string(), token.trim())?;
let dir = crate::console_dir(root);
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let store = monetize_ui_server::state::StateStore::open(root.join(UI_STATE_REL))?;
let webauthn = Arc::new(Webauthn::new(relying_party(&settings.rp_id), store)?);
let ui_dir = match unpack(&dir) {
Ok(Some(d)) => Some(d),
Ok(None) => {
tracing::warn!("console: this binary carries no built bundle — the console's API is served, the bundle is not ([console-not-on-medium])");
None
}
Err(e) => {
tracing::error!(error = %e, "console: the bundle could not be unpacked");
None
}
};
// A32-14, stays a task: the console's reset watch, for the console process's life.
tokio::spawn(watch_reset(root.to_path_buf(), Arc::clone(&webauthn)));
let operator = operator_client(root, &args.server.to_string());
if operator.is_none() {
tracing::warn!("console: no operator bearer under {} — refund and settle are refused until monetize's files are prepared", monetize_front::intake::OPERATOR_TOKEN_REL);
}
// "Update this box" (owner 2026-10-18): the box's own update, from the console.
monetize_ui_server::self_box::install(Arc::new(ConsoleBox { root: root.to_path_buf() }));
let app = router_with(&settings, monetize, operator, webauthn, ui_dir);
let l = tokio::net::TcpListener::bind(args.bind).await.map_err(|e| format!("bind {}: {e}", args.bind))?;
tracing::info!(bind = %args.bind, server = %args.server, rp_id = %settings.rp_id, "console: serving");
axum::serve(l, app.into_make_service_with_connect_info::<SocketAddr>()).await.map_err(|e| e.to_string())
}
/// **This box's half of "Update this box"**: what it runs and what is next, read here (the
/// console runs on the box); the word left for PID 1, which alone can restart the box.
struct ConsoleBox {
root: PathBuf,
}
impl monetize_ui_server::self_box::SelfBox for ConsoleBox {
fn look(&self) -> Result<monetize_ui_server::self_box::SelfLook, String> {
let (r, n, p) = crate::self_update::look(&self.root)?;
let (plan, why) = crate::self_update::plan_words(&p);
Ok(monetize_ui_server::self_box::SelfLook {
app: r.app,
slot: r.slot,
base: r.base,
live_update: r.live_update,
next_app: n.app,
next_build: n.build,
plan: plan.to_string(),
why,
asked: crate::self_update::asked(&self.root),
last: crate::self_update::last(&self.root),
})
}
fn ask_update(&self, by: &str) -> Result<(), String> {
crate::self_update::ask(&self.root, by)
}
/// The box's certificates, read here from the disk PID 1's web half writes (edda
/// estate-front 0.1.4 `web::status`).
fn certs(&self) -> Result<serde_json::Value, String> {
Ok(estate_front::web::status::report_json(&self.root, std::time::SystemTime::now()))
}
/// "Try now": the rule is estate-front's; PID 1's loop takes the ask within seconds.
fn try_cert(&self, host: &str, by: &str) -> Result<String, String> {
let words = estate_front::web::status::try_now(&self.root, host, std::time::SystemTime::now())?;
tracing::warn!("console: {by} asked {host}'s certificate to be tried now");
Ok(words)
}
}
/// The operator's door asked for a passkey reset: every passkey disabled,
/// every session revoked; the break-glass token enrols the first again.
async fn watch_reset(root: PathBuf, webauthn: Arc<Webauthn>) {
loop {
if reset_asked(&root) {
match webauthn.reset_passkeys() {
Ok(n) => {
let _ = std::fs::remove_file(root.join(RESET_REL));
tracing::warn!(disabled = n, "console: passkey reset (the operator's door): every passkey disabled, every session revoked");
}
Err(e) => tracing::error!(error = %e, "console: the passkey reset failed"),
}
}
tokio::time::sleep(POLL).await;
}
}
fn reset_asked(root: &Path) -> bool {
root.join(RESET_REL).exists()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_console_takes_its_three_addresses_and_nothing_else() {
let w = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let a = parse_args(&w(&["--bind", "127.0.0.1:8091", "--server", "127.0.0.1:50061", "--root", "/data"])).unwrap();
assert_eq!(a, UiArgs { bind: "127.0.0.1:8091".parse().unwrap(), server: "127.0.0.1:50061".parse().unwrap(), root: "/data".into() });
assert!(parse_args(&w(&["--bind", "127.0.0.1:8091"])).unwrap_err().contains("--server"));
assert!(parse_args(&w(&["--estates", "x"])).unwrap_err().contains("--estates"));
}
/// One raw HTTP/1.1 GET with `headers` against `router`: `(status, the
/// Location header, the body)`.
async fn get(router: axum::Router, path: &str, headers: &[(&str, &str)]) -> (u16, Option<String>, String) {
use std::io::{Read as _, Write as _};
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = l.local_addr().unwrap();
tokio::spawn(async move { axum::serve(l, router).await.unwrap() });
let (path, headers) = (path.to_string(), headers.iter().map(|(k, v)| format!("{k}: {v}\r\n")).collect::<String>());
tokio::task::spawn_blocking(move || {
let mut s = std::net::TcpStream::connect(at).unwrap();
write!(s, "GET {path} HTTP/1.1\r\nHost: monetize.ignalina.dk\r\n{headers}Connection: close\r\n\r\n").unwrap();
let mut out = String::new();
s.read_to_string(&mut out).unwrap();
let status = out.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0);
let location = out.lines().find_map(|l| l.strip_prefix("location: ").or_else(|| l.strip_prefix("Location: ")).map(str::to_owned));
(status, location, out.split("\r\n\r\n").nth(1).unwrap_or("").to_string())
})
.await
.unwrap()
}
/// ★ UW-3: **the box's own console holds the operator bearer** once monetize's files
/// are prepared (the money fixes go with it); before, it holds none and says so.
#[tokio::test]
async fn the_console_holds_the_operator_bearer_once_prepared() {
let dir = tempfile::tempdir().unwrap();
assert!(operator_client(dir.path(), "127.0.0.1:1").is_none(), "nothing prepared: no operator credential");
monetize_front::intake::prepare(dir.path()).unwrap();
assert!(operator_client(dir.path(), "127.0.0.1:1").is_some());
}
/// ★ **madmin-root-no-login-redirect** (owner on https://monetize.ignalina.dk
/// 2026-10-01): a browser opening `/` (or `/index.html`) without a session
/// got `401 text/plain "log in to use the console"`, the SPA "Your session
/// is no longer valid"; gunnar-ui sends it to the login page. A BROWSER
/// NAVIGATION (what Chrome and Firefox send for a typed address) is 303 →
/// `/login`; a fetch still gets the 401 it can act on.
#[tokio::test]
async fn a_browser_opening_the_console_without_a_session_is_sent_to_login() {
let dir = tempfile::tempdir().unwrap();
let store = monetize_ui_server::state::StateStore::open(dir.path().join("state.redb")).unwrap();
let wa = Arc::new(Webauthn::new(relying_party("monetize.ignalina.dk"), store).unwrap());
let settings = UiSettings { rp_id: "monetize.ignalina.dk".into(), admin_token: Some("Zq3v8pL0xN6wT2yR9kB4mH7sJ1cF5dGa".into()) };
let router = router(&settings, Monetize::connect_lazy("127.0.0.1:1", "t0ken-for-the-console-on-loopback").unwrap(), wa, None);
// What a browser sends for an address typed into it.
let chrome = [("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"), ("sec-fetch-mode", "navigate"), ("sec-fetch-dest", "document")];
for path in ["/", "/index.html"] {
let (status, location, body) = get(router.clone(), path, &chrome).await;
assert_eq!((status, location.as_deref()), (303, Some("/login")), "{path}: {status} {body}");
}
// A navigation that names no text/html (some clients send `*/*`) is
// still a navigation by its Fetch Metadata.
let (status, location, body) = get(router.clone(), "/", &[("accept", "*/*"), ("sec-fetch-mode", "navigate"), ("sec-fetch-dest", "document")]).await;
assert_eq!((status, location.as_deref()), (303, Some("/login")), "a */* navigation: {status} {body}");
// A fetch gets the 401 it can act on.
let (status, _, _) = get(router, "/api/products", &[("accept", "application/json")]).await;
assert_eq!(status, 401);
}
}