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