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

nordisk/monetize

crates/monetize-ui-server/src/main.rs

//! `monetize-ui-server` — the binary.
//!
//! ```text
//! monetize-ui-server [--bind 127.0.0.1:8091] [--control 127.0.0.1:50061] [--token BEARER]
//!                    [--data DIR] [--ui-dir dist/] [--poll-secs 5]
//! monetize-ui-server --unit | --install [--user] | --uninstall [--user] | --start | --stop | --status
//! ```
//!
//! Environment: `MONETIZE_SERVER_TOKEN` (the suite bearer; flag > env > `<data>/token`),
//! `MONETIZE_UI_ADMIN_TOKEN` (break-glass, for the first passkey),
//! `MONETIZE_UI_WEBAUTHN_RP_ID` / `_ORIGIN` / `_RP_NAME` (unset ⇒ passkeys off).
//! No clap: the flag set is a dozen words.

use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use anyhow::{bail, Context};
use monetize_ui_server::app::App;
use monetize_ui_server::auth::{self, RelyingParty, Webauthn};
use monetize_ui_server::grpc::Monetize;
use monetize_ui_server::lifecycle::{self, Settings, ADMIN_TOKEN_ENV, BIN, TOKEN_ENV};
use monetize_ui_server::sse::EventHub;
use monetize_ui_server::{estates, state};
use skidbladnir::systemd::Scope;

#[derive(Default)]
struct Args {
    settings: Option<Settings>,
    token: Option<String>,
    verb: Option<String>,
    user_scope: bool,
}

fn default_data_dir() -> PathBuf {
    std::env::var_os("MONETIZE_UI_DATA")
        .map(PathBuf::from)
        .or_else(|| if nix_is_root() { Some(PathBuf::from(lifecycle::DEFAULT_DATA_DIR)) } else { None })
        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share/monetize-ui")))
        .unwrap_or_else(|| PathBuf::from("."))
}

fn out(text: impl std::fmt::Display) -> anyhow::Result<()> {
    print!("{text}");
    Ok(())
}

fn nix_is_root() -> bool {
    use std::os::unix::fs::MetadataExt;
    std::fs::metadata("/proc/self").is_ok_and(|m| m.uid() == 0)
}

fn parse_args() -> anyhow::Result<Args> {
    let mut a = Args::default();
    let mut s = Settings { data_dir: default_data_dir(), ..Settings::default() };
    let mut it = std::env::args().skip(1);
    let need = |it: &mut std::iter::Skip<std::env::Args>, flag: &str| it.next().ok_or_else(|| anyhow::anyhow!("{flag} needs a value"));
    while let Some(arg) = it.next() {
        match arg.as_str() {
            "--bind" => s.bind = need(&mut it, "--bind")?,
            "--control" => s.control = need(&mut it, "--control")?,
            "--control-at" => s.control_at = Some(need(&mut it, "--control-at")?),
            "--control-ca" => s.control_ca = Some(PathBuf::from(need(&mut it, "--control-ca")?)),
            "--control-key" => s.control_key = Some(PathBuf::from(need(&mut it, "--control-key")?)),
            "--estates" => bail!("{}", estates::LEGACY_ESTATES_FILE),
            "--data" => s.data_dir = PathBuf::from(need(&mut it, "--data")?),
            "--ui-dir" => s.ui_dir = Some(PathBuf::from(need(&mut it, "--ui-dir")?)),
            "--poll-secs" => s.poll_secs = need(&mut it, "--poll-secs")?.parse()?,
            "--token" => a.token = Some(need(&mut it, "--token")?),
            "--user" => a.user_scope = true,
            v @ ("--unit" | "--install" | "--uninstall" | "--start" | "--stop" | "--status" | "--version" | "--help") => a.verb = Some(v.to_string()),
            other => bail!("unknown argument {other:?}; try --help"),
        }
    }
    a.settings = Some(s);
    Ok(a)
}

fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "monetize_ui_server=info,monetize_ui::audit=info,tower_http=warn".into()))
        .init();
    let args = parse_args()?;
    let s = args.settings.clone().unwrap_or_default();
    let (token, whence) = nornir_service::resolve_token_whence(args.token.as_deref(), TOKEN_ENV, Some(&s.data_dir.join("token")));
    let admin_token = std::env::var(ADMIN_TOKEN_ENV).ok().map(|t| t.trim().to_owned()).filter(|t| !t.is_empty());
    let scope = if args.user_scope { Scope::User } else { Scope::System };
    let exe = std::env::current_exe()?;
    let secrets = lifecycle::secret_env(&token, admin_token.as_deref());

    match args.verb.as_deref() {
        Some("--help") => return out(format!("{HELP}\n")),
        Some("--version") => return out(format!("{}\n", monetize_ui_server::VERSION_LINE)),
        Some("--unit") => return out(lifecycle::unit_text(&exe, &s, scope, &secrets)),
        Some("--install") => {
            if let Some(tree) = skidbladnir::systemd::enclosing_cargo_target_dir(&exe) {
                bail!("refusing to install a unit around {} — it is inside the build tree {}; install the binary first", exe.display(), tree.display());
            }
            std::fs::create_dir_all(&s.data_dir)?;
            // Data root first, then the account, then hand the root over — the
            // account's home is NOT the data root (lifecycle::SERVICE_HOME), so
            // `ensure_system_user` re-owns the home only.
            if matches!(scope, Scope::System) {
                let (uid, gid) = lifecycle::ensure_service_user()?;
                eprintln!("{BIN}: user {} = {uid}:{gid}, home {}", lifecycle::SERVICE_USER, lifecycle::SERVICE_HOME);
                lifecycle::own_data_dir(&s, uid, gid)?;
                eprintln!("{BIN}: data root {} owned by {uid}:{gid}", s.data_dir.display());
            }
            lifecycle::install(&exe, &s, scope, &secrets)?;
            println!("installed {BIN} ({scope:?}) listening on {}", s.bind);
            return Ok(());
        }
        Some("--uninstall") => return out(format!("{:?}\n", lifecycle::lifecycle(&exe, &s, scope, &[]).uninstall()?)),
        Some("--start") => return lifecycle::lifecycle(&exe, &s, scope, &[]).start(),
        Some("--stop") => return lifecycle::lifecycle(&exe, &s, scope, &[]).stop(),
        Some("--status") => {
            let lc = lifecycle::lifecycle(&exe, &s, scope, &[]);
            return out(format!("active={} healthy={}\n", lc.status()?, lc.health()?));
        }
        _ => {}
    }

    eprintln!("{BIN}: bearer toward monetize from {}", whence.describe());
    let rt = tokio::runtime::Builder::new_multi_thread().enable_all().build()?;
    rt.block_on(run(s, token, admin_token))
}

/// **The ONE monetize, as `s` names it**: plain gRPC on loopback (the ONE
/// box's own console), or `https://<name>` pinned to its certificate
/// (`--control-ca`, required) with every call signed (`--control-key`) — a
/// console outside the ONE box. It is the only thing this console dials: no
/// estate runs a monetize of its own (ONE monetize, owner 2026-10-10).
fn control(s: &Settings, token: &str) -> anyhow::Result<Monetize> {
    if s.control.starts_with("https://") {
        let ca = s.control_ca.as_ref().ok_or_else(|| anyhow::anyhow!("--control {} is https: --control-ca <the estate's certificate, PEM> is required (the console trusts the monetize it was told, never the public roots)", s.control))?;
        let pem = std::fs::read_to_string(ca).with_context(|| format!("--control-ca {}", ca.display()))?;
        // From outside the ONE box every call is SIGNED with the console's own
        // key (monetize knows it as `monetize-ui`); its front strips any
        // bearer, so there is no other way in.
        let k = s.control_key.as_ref().ok_or_else(|| anyhow::anyhow!("--control {} is https: --control-key <the console's SSH key> is required (from outside every call is signed; no bearer passes the front)", s.control))?;
        let auth = monetize_ui_server::grpc::load_key(k).map_err(anyhow::Error::msg)?;
        return Monetize::connect_pinned_as(&s.control, s.control_at.as_deref(), &pem, auth).map_err(anyhow::Error::msg);
    }
    if s.control_at.is_some() || s.control_ca.is_some() || s.control_key.is_some() {
        bail!("--control-at / --control-ca pin an https control; --control {} is plain", s.control);
    }
    Monetize::connect_lazy(&s.control, token).map_err(anyhow::Error::msg)
}

async fn run(s: Settings, token: String, admin_token: Option<String>) -> anyhow::Result<()> {
    let monetize = control(&s, &token)?;
    std::fs::create_dir_all(&s.data_dir).with_context(|| format!("cannot create {}", s.data_dir.display()))?;
    let store = state::StateStore::open(s.data_dir.join("state.redb")).map_err(anyhow::Error::msg)?;
    let webauthn = match RelyingParty::from_env().map_err(anyhow::Error::msg)? {
        Some(rp) => {
            tracing::info!(rp_id = %rp.rp_id, origin = %rp.origin, "passkey login enabled");
            Some(Arc::new(Webauthn::new(rp, store).map_err(anyhow::Error::msg)?))
        }
        None => {
            tracing::warn!("MONETIZE_UI_WEBAUTHN_RP_ID is unset, so passkey login is off");
            None
        }
    };
    // With neither passkey nor admin token every request runs as admin. That is
    // the development posture, and it is confined to loopback by refusal, not warning.
    if webauthn.is_none() && admin_token.is_none() && !auth::open_console_is_confined(&s.bind) {
        bail!("--bind is {:?} and neither MONETIZE_UI_WEBAUTHN_RP_ID nor MONETIZE_UI_ADMIN_TOKEN is set: this would serve an unauthenticated admin console to anyone who can reach it. Configure a passkey RP or bind 127.0.0.1.", s.bind);
    }
    if let Some(dir) = &s.ui_dir {
        if !dir.join("index.html").is_file() {
            tracing::warn!(dir = %dir.display(), "the ui dir has no index.html; run `trunk build` in crates/monetize-ui-web");
        }
    }
    // ONE app over the ONE monetize: every estate is the fleet's, every
    // customer an account on the ONE ledger (`estates`).
    // A32-14, stays a task: the console's event hub, for the console's life.
    let app = Arc::new(App { webauthn: webauthn.clone(), admin_token: admin_token.clone(), events: EventHub::spawn(monetize.clone(), Duration::from_secs(s.poll_secs.max(1))), monetize, operator: None, ui_dir: s.ui_dir.clone() });
    if let Err(e) = app.monetize.keep_the_fleet() {
        tracing::warn!("the console's fleet is not kept by its job ({e}); each page reads it");
    }
    tracing::info!(bind = %s.bind, control = %s.control, passkey = app.passkey_enabled(), open = app.is_open(), "the console is starting");
    let listener = tokio::net::TcpListener::bind(&s.bind).await.with_context(|| format!("cannot listen on {}", s.bind))?;
    axum::serve(listener, monetize_ui_server::serve::router(app))
        .with_graceful_shutdown(async {
            let _ = tokio::signal::ctrl_c().await;
            tracing::info!("stopping");
        })
        .await
        .context("the server stopped")
}

const HELP: &str = "monetize-ui-server [--bind ADDR] [--control ADDR | --control https://NAME --control-ca PEM [--control-at HOST:PORT] [--control-key SSHKEY]] [--token BEARER] [--data DIR] [--ui-dir DIST] [--poll-secs N] [--user]
  --unit        render the systemd unit
  --install     write + enable + start the unit via skidbladnir (root for system scope)
  --uninstall   stop + disable + erase the data root (the passkey store)
  --start | --stop | --status
--control     the ONE monetize, and the only thing dialled: every estate is the fleet's, every customer an account on the ONE ledger
--estates     LEGACY (a per-estate monetize each): refused
An https control (outside the ONE box) signs every call with --control-key; no bearer.
Bearer toward a loopback monetize: --token > $MONETIZE_SERVER_TOKEN > <data>/token > open.
Passkeys: MONETIZE_UI_WEBAUTHN_RP_ID / _ORIGIN / _RP_NAME; MONETIZE_UI_ADMIN_TOKEN enrols the first admin and retires.";