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/lifecycle.rs

//! **monetize-ui-server as a skidbladnir-managed service**, the shape
//! monetize-server's `lifecycle.rs` has: one `ServiceSpec`, one `Lifecycle`;
//! `--unit` renders it, `--install`/`--uninstall`/`--start`/`--stop`/`--status`
//! drive it. User `monetize-ui`, data root `/var/lib/monetize-ui` (the passkey
//! store — DECLARED, so uninstall erases it), secrets via the 0600 env file.

use std::path::{Path, PathBuf};

use skidbladnir::service::{AgentMode, HealthProbe, Lifecycle, ServiceSpec};
use skidbladnir::systemd::{RestartPolicy, Scope};

pub const BIN: &str = "monetize-ui-server";
pub const SERVICE_USER: &str = "monetize-ui";
pub const DEFAULT_DATA_DIR: &str = "/var/lib/monetize-ui";
/// The service account's HOME — deliberately not [`DEFAULT_DATA_DIR`], for the
/// reason `monetize_server::config::SERVICE_HOME` records: skidbladnir refuses a
/// data root that IS an account's home, so a service whose home and data root are
/// one directory declares no root at all and `--uninstall` erases nothing.
pub const SERVICE_HOME: &str = "/home/monetize-ui";
pub const TOKEN_ENV: &str = "MONETIZE_SERVER_TOKEN";
pub const ADMIN_TOKEN_ENV: &str = "MONETIZE_UI_ADMIN_TOKEN";
/// Forwarded into the unit when set at install: the passkey relying party.
pub const WEBAUTHN_ENVS: &[&str] = &[
    "MONETIZE_UI_WEBAUTHN_RP_ID",
    "MONETIZE_UI_WEBAUTHN_ORIGIN",
    "MONETIZE_UI_WEBAUTHN_RP_NAME",
];

/// What the binary was started with; the unit re-states it as flags.
#[derive(Debug, Clone)]
pub struct Settings {
    pub bind: String,
    pub control: String,
    /// Dial this `host:port` under `control`'s name (an https control only).
    pub control_at: Option<String>,
    /// The ONLY trust for an https control: the ONE monetize's certificate, PEM.
    pub control_ca: Option<PathBuf>,
    /// The console's SSH key (OpenSSH private key): every call to an https
    /// control is SIGNED with it (the `monetize-ui` principal), never a bearer.
    pub control_key: Option<PathBuf>,
    pub data_dir: PathBuf,
    pub ui_dir: Option<PathBuf>,
    pub poll_secs: u64,
}

impl Default for Settings {
    fn default() -> Self {
        Settings {
            bind: crate::DEFAULT_BIND.into(),
            control: format!("127.0.0.1:{}", monetize_proto::DEFAULT_PORT),
            control_at: None,
            control_ca: None,
            control_key: None,
            data_dir: PathBuf::from(DEFAULT_DATA_DIR),
            ui_dir: None,
            poll_secs: 5,
        }
    }
}

pub fn service_spec(
    exec: &Path,
    s: &Settings,
    scope: Scope,
    secret_env: &[(String, String)],
) -> ServiceSpec {
    let data = s.data_dir.display().to_string();
    let port = skidbladnir::service::split_bind_addr(&s.bind)
        .map(|(_, p)| p)
        .unwrap_or(8091);
    let mut spec = ServiceSpec::new(
        BIN,
        "monetize console — the operator's view of plans, tenants, entitlement and the ledger",
        exec.display().to_string(),
    )
    .scope(scope)
    .user(SERVICE_USER)
    .working_dir(data.as_str())
    .arg("--bind")
    .arg(s.bind.clone())
    .arg("--control")
    .arg(s.control.clone())
    .arg("--data")
    .arg(data.clone())
    .arg("--poll-secs")
    .arg(s.poll_secs.to_string())
    .env("MONETIZE_UI_DATA", data.as_str())
    .env("HOME", SERVICE_HOME)
    .health(HealthProbe::Tcp(port))
    .restart(RestartPolicy::OnFailure)
    .agent(AgentMode::Fat)
    .give_up_after(5);
    if let Some(ui) = &s.ui_dir {
        spec = spec.arg("--ui-dir").arg(ui.display().to_string());
    }
    if let Some(at) = &s.control_at {
        spec = spec.arg("--control-at").arg(at.clone());
    }
    if let Some(k) = &s.control_key {
        spec = spec.arg("--control-key").arg(k.display().to_string());
    }
    if let Some(ca) = &s.control_ca {
        spec = spec.arg("--control-ca").arg(ca.display().to_string());
    }
    for (k, v) in WEBAUTHN_ENVS
        .iter()
        .filter_map(|k| std::env::var(k).ok().map(|v| (k, v)))
        .filter(|(_, v)| !v.trim().is_empty())
    {
        spec = spec.env(*k, v.trim());
    }
    for (k, v) in secret_env {
        spec = spec.secret_env(k.as_str(), v.as_str());
    }
    match skidbladnir::uninstall::DataRoot::from_env("MONETIZE_UI_DATA", &s.data_dir) {
        Ok(dr) => spec = spec.data_root(&dr),
        Err(e) => eprintln!("{BIN}: {e} — uninstall would erase nothing; not declaring a root"),
    }
    spec
}

pub fn lifecycle(
    exec: &Path,
    s: &Settings,
    scope: Scope,
    secret_env: &[(String, String)],
) -> Lifecycle {
    Lifecycle::new(service_spec(exec, s, scope, secret_env))
}

/// The 0600 `EnvironmentFile=` the secrets go to; `validate` refuses secrets without it.
pub fn env_file_path() -> String {
    "/etc/monetize/monetize-ui.env".into()
}

pub fn systemd_spec(
    exec: &Path,
    s: &Settings,
    scope: Scope,
    secret_env: &[(String, String)],
) -> skidbladnir::systemd::SystemdServiceSpec {
    lifecycle(exec, s, scope, secret_env)
        .systemd_spec()
        .with_env_file(env_file_path())
}

pub fn unit_text(
    exec: &Path,
    s: &Settings,
    scope: Scope,
    secret_env: &[(String, String)],
) -> String {
    skidbladnir::systemd::systemd_unit_scoped(&systemd_spec(exec, s, scope, secret_env), scope)
}

#[cfg(unix)]
pub fn install(
    exec: &Path,
    s: &Settings,
    scope: Scope,
    secret_env: &[(String, String)],
) -> anyhow::Result<()> {
    let sys = systemd_spec(exec, s, scope, secret_env);
    sys.validate().map_err(|e| anyhow::anyhow!(e))?;
    skidbladnir::systemd::install_systemd_scoped(&sys, scope)
}

/// The secrets the unit carries: the suite bearer and the break-glass token, when given.
pub fn secret_env(token: &str, admin_token: Option<&str>) -> Vec<(String, String)> {
    let mut v = Vec::new();
    if !token.is_empty() {
        v.push((TOKEN_ENV.to_string(), token.to_string()));
    }
    if let Some(a) = admin_token.filter(|a| !a.is_empty()) {
        v.push((ADMIN_TOKEN_ENV.to_string(), a.to_string()));
    }
    v
}

#[cfg(unix)]
pub fn ensure_service_user() -> anyhow::Result<(u32, u32)> {
    skidbladnir::systemd::ensure_system_user(SERVICE_USER, SERVICE_HOME)
}

/// Hand the data root (the passkey store) to the service account, recursively —
/// the account's home is not the data root, so it needs its own hand-over. Run
/// AFTER anything the install writes in there.
#[cfg(unix)]
pub fn own_data_dir(s: &Settings, uid: u32, gid: u32) -> anyhow::Result<()> {
    let root = s.data_dir.as_path();
    skidbladnir::systemd::chown_recursive_under(root, root, uid, gid)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_unit_names_the_user_the_flags_the_port_and_keeps_the_secrets_out() {
        let s = Settings {
            ui_dir: Some(PathBuf::from("/opt/monetize-ui/dist")),
            ..Settings::default()
        };
        let unit = unit_text(
            Path::new("/usr/local/bin/monetize-ui-server"),
            &s,
            Scope::System,
            &secret_env("s3cret", Some("br34k")),
        );
        assert!(unit.contains("User=monetize-ui"), "{unit}");
        assert!(unit.contains("ExecStart=/usr/local/bin/monetize-ui-server --bind 127.0.0.1:8091 --control 127.0.0.1:50061 --data /var/lib/monetize-ui --poll-secs 5 --ui-dir /opt/monetize-ui/dist"), "{unit}");
        assert!(
            unit.contains("EnvironmentFile=/etc/monetize/monetize-ui.env"),
            "{unit}"
        );
        assert!(
            !unit.contains("s3cret") && !unit.contains("br34k"),
            "secrets must not be inline in the 0644 unit:\n{unit}"
        );
        // skidbladnir 0.2 no longer refuses a bare lifecycle that carries secrets: it derives
        // an env file for them (`/etc/<name>/<name>.env`). What must hold is unchanged: the
        // projection is valid, and the secret is in an EnvironmentFile=, never inline.
        let bare = lifecycle(
            Path::new("/x"),
            &s,
            Scope::System,
            &secret_env("s3cret-bare", None),
        )
        .systemd_spec();
        assert!(bare.validate().is_ok(), "{:?}", bare.validate());
        let bare_unit = skidbladnir::systemd::systemd_unit_scoped(&bare, Scope::System);
        assert!(
            !bare_unit.contains("s3cret-bare") && bare_unit.contains("EnvironmentFile="),
            "a bare lifecycle's secret must go to an env file:\n{bare_unit}"
        );
        let spec = service_spec(Path::new("/x"), &s, Scope::System, &[]);
        assert_eq!(spec.data_root.as_deref(), Some(DEFAULT_DATA_DIR));
        assert_eq!(spec.health, HealthProbe::Tcp(8091));
    }
}