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.";