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

nordisk/monetize

crates/monetize-server/src/lifecycle.rs

//! **monetize-server as a skidbladnir-managed service** (every service in the
//! suite goes through skidbladnir). One [`ServiceSpec`], one [`Lifecycle`];
//! `--unit` renders it,
//! `--install`/`--uninstall`/`--start`/`--stop`/`--status` drive it over D-Bus.
//! Nothing here shells out.
//!
//! * user `monetize` (system scope), locked, `nologin`, home = the data dir;
//! * data root `/var/lib/monetize` — DECLARED, so `uninstall` erases the ledger,
//!   the usage store and the pools' state (the suite's erase-data law) through
//!   skidbladnir's guarded eraser;
//! * secrets (the bearer, vendor credentials) go through `secret_env` → the 0600
//!   `EnvironmentFile=`, never inline in the 0644 unit.

use std::path::Path;

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

use crate::config::{Config, SERVICE_HOME, SERVICE_USER, TOKEN_ENV};

pub const SERVER_BIN: &str = "monetize-server";

pub fn service_spec(
    exec: &Path,
    config_path: &Path,
    cfg: &Config,
    scope: Scope,
    secret_env: &[(String, String)],
) -> ServiceSpec {
    let data = cfg.data_dir.display().to_string();
    let mut spec = ServiceSpec::new(
        SERVER_BIN,
        "monetize — orders, entitlement, the reserve→provision→capture transaction",
        exec.display().to_string(),
    )
    .scope(scope)
    .user(SERVICE_USER)
    .working_dir(data.as_str())
    .arg("--config")
    .arg(config_path.display().to_string())
    .env("MONETIZE_DATA", data.as_str())
    // HOME is the ACCOUNT's home, never the data root — see `config::SERVICE_HOME`.
    // A `$HOME` that is the data root is the shape skidbladnir's guard names.
    .env("HOME", SERVICE_HOME)
    .health(HealthProbe::Tcp(cfg.grpc_addr.port()))
    .restart(RestartPolicy::OnFailure)
    .agent(AgentMode::Fat)
    .give_up_after(5);
    for (k, v) in secret_env {
        spec = spec.secret_env(k.as_str(), v.as_str());
    }
    match skidbladnir::uninstall::DataRoot::from_env("MONETIZE_DATA", &cfg.data_dir) {
        Ok(dr) => spec = spec.data_root(&dr),
        Err(e) => {
            eprintln!("monetize-server: {e} — uninstall would erase nothing; not declaring a root")
        }
    }
    spec
}

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

/// The 0600 `EnvironmentFile=` the secrets go to. `ServiceSpec` has no env-file
/// field; the sanctioned shape is the systemd-level spec with one declared, and
/// `SystemdServiceSpec::validate` REFUSES secrets without it.
pub fn env_file_path(config_path: &Path) -> String {
    config_path.with_extension("env").display().to_string()
}

/// The systemd spec the install writes: the lifecycle's, plus the env file.
pub fn systemd_spec(
    exec: &Path,
    config_path: &Path,
    cfg: &Config,
    scope: Scope,
    secret_env: &[(String, String)],
) -> skidbladnir::systemd::SystemdServiceSpec {
    lifecycle(exec, config_path, cfg, scope, secret_env)
        .systemd_spec()
        .with_env_file(env_file_path(config_path))
}

/// The unit text `--unit` prints — with the secrets routed to the env file.
pub fn unit_text(
    exec: &Path,
    config_path: &Path,
    cfg: &Config,
    scope: Scope,
    secret_env: &[(String, String)],
) -> String {
    skidbladnir::systemd::systemd_unit_scoped(
        &systemd_spec(exec, config_path, cfg, scope, secret_env),
        scope,
    )
}

/// Why an install must not proceed with no data root declared, as one line.
///
/// A [`ServiceSpec`] with no `data_root` renders and installs perfectly happily,
/// and the service then runs correctly — the only thing missing is that
/// `--uninstall` erases NOTHING and says success, i.e. the suite's uninstall law
/// quietly not applying. That is precisely the class of defect that reaches a
/// real box, because everything an operator looks at says fine. Measured
/// 2026-09-04 on gunnar.rs, where the refusal was printed as a warning and the
/// install exited 0.
///
/// So the install REFUSES. It is also the only door that can: `--unit` must stay
/// renderable for inspection on any box, and a box whose account was created by
/// an older install (home == the data root) is exactly what needs telling.
pub fn data_root_or_refuse(spec: &ServiceSpec) -> anyhow::Result<&str> {
    spec.data_root.as_deref().ok_or_else(|| {
        anyhow::anyhow!(
            "refusing to install with no data root declared: `--uninstall` would erase nothing and \
             report success, which is the suite's erase-data law silently not applying. The reason \
             was printed above. If this box's `{SERVICE_USER}` account was created by an older \
             install, its home IS the data root and must be moved to {SERVICE_HOME} first."
        )
    })
}

/// `--install`: write the unit + the 0600 env file, daemon-reload, enable, start.
#[cfg(unix)]
pub fn install(
    exec: &Path,
    config_path: &Path,
    cfg: &Config,
    scope: Scope,
    secret_env: &[(String, String)],
) -> anyhow::Result<()> {
    let sys = systemd_spec(exec, config_path, cfg, scope, secret_env);
    data_root_or_refuse(&service_spec(exec, config_path, cfg, scope, secret_env))?;
    sys.validate().map_err(|e| anyhow::anyhow!(e))?;
    skidbladnir::systemd::install_systemd_scoped(&sys, scope)
}

/// The secret env the unit carries: the bearer, plus every vendor credential
/// [`crate::registry::vendor_config_envs`] names that is set in the operator's
/// OWN environment at install time.
///
/// That forwarding is the whole reason this exists. The vendor credentials are
/// deliberately NOT in the 0644 `monetize.toml` (see `config`'s module doc), and
/// each vendor's `from_env` reads them from the process environment — so without
/// this an `--install` of a server with `[vendors.invoice.gunnar] enabled = true` wrote
/// a unit whose service died at start with *"MONETIZE_INVOICE_IMPORT_TOKEN not
/// set"*. The operator's shell is the private place they come from:
///
/// ```text
/// MONETIZE_INVOICE_IMPORT_TOKEN=… monetize-server --install
/// ```
///
/// An empty or blank value falls THROUGH rather than being written as "set to
/// empty", the same rule the bearer ladder follows.
pub fn secret_env(token: &str, linked: &crate::registry::Linked) -> Vec<(String, String)> {
    secret_env_from(token, linked, |k| std::env::var(k).ok())
}

/// [`secret_env`] plus the names a CONFIG reads: a product's
/// ([`crate::config::Config::product_config_envs`]) and each vendor instance's
/// ([`crate::registry::Linked::vendor_instance_envs`]). Products are configured
/// rather than linked, so their env names cannot come from `Linked`; this is
/// the one place the two lists meet, and the rule is the same — a name set to
/// nothing is left out, never forwarded empty.
pub fn secret_env_with(
    token: &str,
    linked: &crate::registry::Linked,
    product_envs: &[String],
) -> Vec<(String, String)> {
    let mut env = secret_env(token, linked);
    for name in product_envs {
        if name == crate::config::TOKEN_ENV || env.iter().any(|(k, _)| k == name) {
            continue;
        }
        if let Some(value) = std::env::var(name).ok().filter(|v| !v.trim().is_empty()) {
            env.push((name.clone(), value));
        }
    }
    env
}

/// [`secret_env`] with the environment lookup handed in — pure, so a test can
/// assert what reaches the env file without touching the process environment
/// (which no parallel test may do safely).
pub fn secret_env_from(
    token: &str,
    linked: &crate::registry::Linked,
    lookup: impl Fn(&str) -> Option<String>,
) -> Vec<(String, String)> {
    let mut env = Vec::new();
    if !token.is_empty() {
        env.push((TOKEN_ENV.to_string(), token.to_string()));
    }
    for name in linked
        .vendor_config_envs()
        .into_iter()
        .chain(linked.cloud_config_envs())
        .chain(linked.signup_config_envs())
    {
        // The bearer is already above, from the ladder that resolved it; a second
        // entry from the raw environment would be a second writer of one value.
        if name == TOKEN_ENV {
            continue;
        }
        if let Some(value) = lookup(name).filter(|v| !v.trim().is_empty()) {
            env.push((name.to_string(), value));
        }
    }
    env
}

/// Create the `monetize` system user with [`SERVICE_HOME`] as its home
/// (idempotent, root). Pure Rust via skidbladnir, no `useradd`.
#[cfg(unix)]
pub fn ensure_service_user() -> anyhow::Result<(u32, u32)> {
    skidbladnir::systemd::ensure_system_user(SERVICE_USER, SERVICE_HOME)
}

/// Give the data root to the service account, recursively.
///
/// `ensure_system_user` re-owns the account's HOME, and the data root is
/// deliberately not that (see [`SERVICE_HOME`]) — so the data root needs its own
/// hand-over. It runs AFTER the install has minted the signing key and the
/// product identities, so **whatever the install created in there is owned by the
/// account that has to read it**, rather than by the root that made it: a
/// 0600 root-owned `signing.key` under a monetize-owned directory is a service
/// that starts and dies on EACCES.
#[cfg(unix)]
pub fn own_data_dir(cfg: &Config, uid: u32, gid: u32) -> anyhow::Result<()> {
    let root = cfg.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_data_dir_the_port_and_keeps_the_secret_out() {
        let cfg = Config::default();
        let unit = unit_text(
            Path::new("/usr/local/bin/monetize-server"),
            Path::new("/etc/monetize/monetize.toml"),
            &cfg,
            Scope::System,
            &secret_env("s3cret-bearer", &crate::registry::Linked::open()),
        );
        assert!(unit.contains("User=monetize"), "{unit}");
        assert!(
            unit.contains(
                "ExecStart=/usr/local/bin/monetize-server --config /etc/monetize/monetize.toml"
            ),
            "{unit}"
        );
        assert!(
            unit.contains("WorkingDirectory=/var/lib/monetize"),
            "{unit}"
        );
        assert!(unit.contains("MONETIZE_DATA=/var/lib/monetize"), "{unit}");
        assert!(unit.contains("Restart=on-failure"), "{unit}");
        assert!(
            !unit.contains("s3cret-bearer"),
            "the bearer must not be inline in the 0644 unit:\n{unit}"
        );
        assert!(
            unit.contains("EnvironmentFile=/etc/monetize/monetize.env"),
            "{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"),
            Path::new("/c"),
            &cfg,
            Scope::System,
            &secret_env("s3cret-bare", &crate::registry::Linked::open()),
        )
        .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"), Path::new("/c"), &cfg, Scope::System, &[]);
        assert_eq!(spec.data_root.as_deref(), Some("/var/lib/monetize"));
        assert_eq!(spec.health, HealthProbe::Tcp(50061));
    }

    /// An install with no data root declared must FAIL, not warn and exit 0.
    ///
    /// This is the guard that would have caught the bug above at deploy time
    /// instead of at read-the-log time: on gunnar.rs the refusal was printed, the
    /// install returned success, the service ran correctly, and `--uninstall`
    /// would silently have erased nothing. Everything an operator looks at said
    /// fine.
    #[test]
    fn refuse_twin_an_install_with_no_data_root_is_refused_not_warned() {
        let cfg = Config::default();
        // The happy path: a spec that DID declare one passes through.
        let good = service_spec(Path::new("/x"), Path::new("/c"), &cfg, Scope::System, &[]);
        assert_eq!(good.data_root.as_deref(), Some("/var/lib/monetize"));
        assert_eq!(data_root_or_refuse(&good).unwrap(), "/var/lib/monetize");

        // The shape a box whose account was made by an older install produces:
        // `DataRoot::from_env` refused, so `service_spec` declared nothing.
        let mut orphan = good;
        orphan.data_root = None;
        let err = data_root_or_refuse(&orphan).expect_err("no data root must refuse the install");
        let msg = err.to_string();
        assert!(
            msg.contains("erase nothing"),
            "the reason must name the consequence: {msg}"
        );
        assert!(msg.contains(SERVICE_HOME), "and the remedy: {msg}");
    }

    /// **The data root must survive the account existing.**
    ///
    /// The neighbouring test asserts `spec.data_root == Some("/var/lib/monetize")`
    /// and could never fail: `DataRoot::from_env` consults the LIVE `/etc/passwd`,
    /// where no `monetize` account exists on any build host — so the one condition
    /// that refuses the declaration is the one condition a test never creates.
    /// On the gunnar.rs box, where the account DOES exist, the same code printed
    /// "REFUSING /var/lib/monetize as a data root: it IS account `monetize`'s home
    /// directory — uninstall would erase nothing", and `--uninstall` would have
    /// erased nothing while reporting success.
    ///
    /// So this asks the question against a FIXTURE passwd that has the account in
    /// it, which is red for `home == data root` and green once they are split.
    #[test]
    fn the_data_root_is_still_declared_on_a_box_where_the_service_account_exists() {
        let dir = std::env::temp_dir().join(format!("monetize-passwd-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let passwd = dir.join("passwd");
        let cfg = Config::default();
        let data = cfg.data_dir.display().to_string();
        std::fs::write(
            &passwd,
            format!("root:x:0:0::/root:/bin/sh\n{SERVICE_USER}:x:110:110::{SERVICE_HOME}:/usr/sbin/nologin\n"),
        )
        .unwrap();

        // The account is in passwd with SERVICE_HOME as its home, and the data root
        // is a different directory — so it is accepted and uninstall has something
        // to erase.
        let root = skidbladnir::uninstall::DataRoot::new_in(&data, &passwd)
            .unwrap_or_else(|e| panic!("the data root must survive the account existing: {e}"));
        assert_eq!(root.path(), cfg.data_dir.as_path());

        // And the RED that was live until 2026-09-04: the same account with the
        // data root AS its home is refused by name, which is why they are split.
        std::fs::write(
            &passwd,
            format!(
                "root:x:0:0::/root:/bin/sh\n{SERVICE_USER}:x:110:110::{data}:/usr/sbin/nologin\n"
            ),
        )
        .unwrap();
        let refused = skidbladnir::uninstall::DataRoot::new_in(&data, &passwd)
            .expect_err("home == data root must be refused");
        assert_eq!(
            refused.reason,
            skidbladnir::uninstall::DataRootRefusal::IsUserHome {
                user: SERVICE_USER.to_string()
            },
            "{refused}"
        );

        // The unit therefore points HOME at the account's home, not at the data
        // root — a `$HOME`-derived state dir is the shape the guard forbids.
        let unit = unit_text(
            Path::new("/usr/local/bin/monetize-server"),
            Path::new("/etc/monetize/monetize.toml"),
            &cfg,
            Scope::System,
            &[],
        );
        assert!(
            unit.contains(&format!("Environment=HOME={SERVICE_HOME}")),
            "{unit}"
        );
        assert!(
            !unit.contains(&format!("Environment=HOME={data}")),
            "{unit}"
        );
        assert!(
            unit.contains(&format!("WorkingDirectory={data}")),
            "the working dir stays the data root: {unit}"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }

    /// RED before `secret_env` forwarded them: `--install` of a server with
    /// `[vendors.invoice.gunnar] enabled = true` wrote a unit with nothing but the
    /// A product's key rides the same ladder as a vendor's: forwarded when the
    /// named variable is set, left out when it is empty, and never a second
    /// copy of the bearer. a product block's `api_key_env` is the first user.
    #[test]
    fn product_env_names_ride_the_secret_ladder() {
        let open = crate::registry::Linked::open();
        // SAFETY: test-only names, set before the only read.
        unsafe {
            std::env::set_var("PRODUCT_LADDER_KEY", "yvra_live_x_y");
            std::env::set_var("PRODUCT_LADDER_EMPTY", "   ");
        }
        let env = secret_env_with(
            "bearer",
            &open,
            &[
                "PRODUCT_LADDER_KEY".into(),
                "PRODUCT_LADDER_EMPTY".into(),
                TOKEN_ENV.into(),
            ],
        );
        let has = |k: &str| env.iter().any(|(n, _)| n == k);
        assert!(
            has("PRODUCT_LADDER_KEY"),
            "a set product key is forwarded: {env:?}"
        );
        assert!(
            !has("PRODUCT_LADDER_EMPTY"),
            "an empty value is left out, never forwarded blank"
        );
        assert_eq!(
            env.iter().filter(|(n, _)| n == TOKEN_ENV).count(),
            1,
            "the bearer is never duplicated"
        );
    }

    /// bearer in its env file, and the service died at start with
    /// "invoice: MONETIZE_INVOICE_IMPORT_TOKEN not set". Proven on the real
    /// binary before the fix, 2026-09-04.
    ///
    /// Both directions, so neither arm can be vacuous: a vendor that IS linked
    /// must appear, one that is NOT must not — `cargo test` (no features) and
    /// `cargo test --all-features` each exercise the other half.
    #[test]
    fn the_env_file_carries_every_linked_vendors_credentials_and_nothing_else() {
        use crate::registry::Linked;
        let open = Linked::open();
        let all = secret_env_from("bearer", &open, |k| Some(format!("value-of-{k}")));
        let has = |k: &str| all.iter().any(|(name, _)| name == k);
        assert!(
            has(TOKEN_ENV),
            "the bearer is still the first secret: {all:?}"
        );

        #[cfg(feature = "invoice")]
        assert!(
            has("MONETIZE_INVOICE_IMPORT_TOKEN") && has("MONETIZE_INVOICE_BANKGIRO"),
            "{all:?}"
        );
        #[cfg(not(feature = "invoice"))]
        assert!(
            !has("MONETIZE_INVOICE_IMPORT_TOKEN"),
            "invoice is not linked into this build: {all:?}"
        );
        #[cfg(feature = "sandbox")]
        assert!(
            has("MONETIZE_SANDBOX_CALLBACK_SECRET") && has("MONETIZE_SANDBOX_CLOUD_STATE"),
            "{all:?}"
        );
        #[cfg(not(feature = "sandbox"))]
        assert!(
            !has("MONETIZE_SANDBOX_CALLBACK_SECRET"),
            "sandbox is not linked into this build: {all:?}"
        );
        // THE OPEN BUILD NEVER CARRIES A PAID CREDENTIAL, whatever its features:
        // those names live in the private impls' factories, which only the vetra
        // build registers. Unconditional, because there is no feature here that
        // could make it true.
        for paid in [
            "MONETIZE_STRIPE_WEBHOOK_SECRET",
            "MONETIZE_KLARNA_PASSWORD",
            "UPCLOUD_TOKEN",
            "UPCLOUD_PASSWORD",
        ] {
            assert!(!has(paid), "{paid} is a paid impl's secret and the open build has no factory that names it: {all:?}");
        }
        // A registered factory's names ARE carried — a CLOUD's credential must
        // reach the unit, or the service starts with no way to authenticate.
        fn never(
            _: &monetize_cloud::factory::CloudBuild<'_>,
        ) -> Result<Box<dyn monetize_cloud::Cloud>, String> {
            Err("never".into())
        }
        let mut vetra = Linked::open();
        vetra.cloud(monetize_cloud::factory::CloudFactory {
            name: "acme-cloud",
            config_envs: &["ACME_TOKEN"],
            spends: true,
            build: never,
        });
        let with = secret_env_from("bearer", &vetra, |k| Some(format!("value-of-{k}")));
        assert!(
            with.iter()
                .any(|(k, v)| k == "ACME_TOKEN" && v == "value-of-ACME_TOKEN"),
            "{with:?}"
        );

        // Paths the unit already states as world-readable `Environment=`, and the
        // console's own break-glass token, are not this server's secrets.
        assert!(
            !has("MONETIZE_DATA") && !has("MONETIZE_UI_ADMIN_TOKEN"),
            "{all:?}"
        );
        // One writer per value: the bearer comes from the ladder, never twice.
        assert_eq!(
            all.iter().filter(|(k, _)| k == TOKEN_ENV).count(),
            1,
            "{all:?}"
        );
        // Blank falls THROUGH, exactly as the bearer ladder treats an empty source.
        assert!(secret_env_from("", &open, |_| Some("   ".into())).is_empty());
        assert!(secret_env_from("", &open, |_| None).is_empty());

        // The applied output: the value reaches the 0600 env file and NEVER the
        // 0644 unit, whatever the vendor set is.
        let cfg = Config::default();
        let secrets = secret_env_from("bearer", &crate::registry::Linked::open(), |k| {
            Some(format!("value-of-{k}"))
        });
        let unit = unit_text(
            Path::new("/usr/local/bin/monetize-server"),
            Path::new("/etc/monetize/monetize.toml"),
            &cfg,
            Scope::System,
            &secrets,
        );
        for (name, value) in &secrets {
            assert!(
                !unit.contains(value.as_str()),
                "{name} leaked into the 0644 unit:\n{unit}"
            );
        }
        let sys = systemd_spec(
            Path::new("/usr/local/bin/monetize-server"),
            Path::new("/etc/monetize/monetize.toml"),
            &cfg,
            Scope::System,
            &secrets,
        );
        let rendered = skidbladnir::systemd::env_file(&sys);
        for (name, value) in &secrets {
            assert!(
                rendered.contains(&format!("{name}={value}")),
                "{name} never reached the env file:\n{rendered}"
            );
        }
    }
}