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