Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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);
    }
}