From adc73565ef6629c9915c6ee7a3af535fe4c7ed60 Mon Sep 17 00:00:00 2001 From: fosskar <117449098+fosskar@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:35:57 +0200 Subject: [PATCH 1/2] cleanup: scheduled auto-cleanup via systemd timer Session files accumulate without bound; ssync cleanup exists but is manual. Add scheduling via systemd timers, not a daemon-internal scheduler (issue #24). New subcommand: ssync cleanup-timer enable|disable|status installs a ssync-cleanup.service/.timer pair running ssync cleanup --apply, user units as a regular user, system units as root with --user/--config under the same mode rules as service install. The oneshot reuses the daemon hardening set, sandboxed to the session dirs. --every accepts 2d/7d-style periods, weekly, or a raw calendar expression (validated with systemd-analyze at enable time). Expressible durations become calendar shorthands and gain Persistent=true catch-up; other day counts run on unit uptime via OnUnitActiveSec. --keep defaults to 90d unless --unnamed is the only selector. NixOS and home-manager modules gain services.ssync.autoCleanup { enable, schedule, keep, unnamed }; the shared hardening set is factored into one attrset used by both the daemon and the cleanup oneshot. The vm-module test covers the rendered ExecStart, the enabled timer, and a live oneshot run. Deletions propagate mesh-wide through the tombstone path: a timer on one machine prunes all machines. Documented loudly in setup.md next to the options. Closes #24. --- Cargo.lock | 10 +- Cargo.toml | 2 +- crates/ssync/src/cleanup_timer.rs | 488 ++++++++++++++++++++++++++++++ crates/ssync/src/main.rs | 53 ++++ crates/ssync/src/service.rs | 60 ++-- docs/setup.md | 42 +++ nix/hm-module.nix | 154 +++++++--- nix/nixos-module.nix | 156 +++++++--- nix/vm-module-test.nix | 16 +- 9 files changed, 865 insertions(+), 116 deletions(-) create mode 100644 crates/ssync/src/cleanup_timer.rs diff --git a/Cargo.lock b/Cargo.lock index 7100085..75840ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4314,7 +4314,7 @@ dependencies = [ [[package]] name = "ssync" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "clap", @@ -4329,14 +4329,14 @@ dependencies = [ [[package]] name = "ssync-adapters" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", ] [[package]] name = "ssync-core" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "dirs", @@ -4352,7 +4352,7 @@ dependencies = [ [[package]] name = "ssync-crypto" -version = "0.11.1" +version = "0.12.0" dependencies = [ "age", "anyhow", @@ -4361,7 +4361,7 @@ dependencies = [ [[package]] name = "ssync-net" -version = "0.11.1" +version = "0.12.0" dependencies = [ "anyhow", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 6094c4c..387ffc1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.11.1" +version = "0.12.0" edition = "2024" license = "MIT" repository = "https://github.com/fosskar/ssync" diff --git a/crates/ssync/src/cleanup_timer.rs b/crates/ssync/src/cleanup_timer.rs new file mode 100644 index 0000000..cb59dcd --- /dev/null +++ b/crates/ssync/src/cleanup_timer.rs @@ -0,0 +1,488 @@ +//! `ssync cleanup-timer enable|disable|status` — scheduled auto-cleanup via a +//! systemd timer/service pair running `ssync cleanup --apply` (issue #24). +//! Scheduling belongs to systemd, not a daemon-internal scheduler. Deletions +//! propagate mesh-wide through the daemon's tombstone path: a timer on ONE +//! machine prunes ALL machines. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result, bail, ensure}; +use ssync_core::Config; + +use crate::service::{ + HARDENING, check_access, config_uses_tilde, create_write_paths, ensure_unit_safe, is_root, + service_user_of, systemctl, unit_dir, +}; + +const SERVICE_NAME: &str = "ssync-cleanup.service"; +const TIMER_NAME: &str = "ssync-cleanup.timer"; + +/// How the timer fires. Calendar schedules get `Persistent=true` (a machine +/// that slept through the window catches up); day counts `OnCalendar` cannot +/// express become monotonic timers instead. +#[derive(Debug, PartialEq)] +pub enum Schedule { + /// A systemd calendar expression (`weekly`, `*-*-* 03:00:00`, ...). + Calendar(String), + /// Fire every N days of unit uptime (`OnUnitActiveSec`), first run + /// shortly after boot. + EveryDays(u64), +} + +/// Parse `--every`: `d`/`w` durations or a calendar expression passed +/// through verbatim (validated against `systemd-analyze` at enable time). +/// Expressible durations (1d, 1w, 7d) become the equivalent calendar +/// shorthand so they gain catch-up semantics. +pub fn schedule_of(every: &str) -> Result { + let days = |suffix: char, per: u64| { + every + .strip_suffix(suffix) + .and_then(|n| n.parse::().ok()) + .map(|n| n * per) + }; + if let Some(n) = days('d', 1).or_else(|| days('w', 7)) { + ensure!(n > 0, "--every {every} never fires"); + return Ok(match n { + 1 => Schedule::Calendar("daily".into()), + 7 => Schedule::Calendar("weekly".into()), + _ => Schedule::EveryDays(n), + }); + } + ensure!(!every.trim().is_empty(), "--every must not be empty"); + Ok(Schedule::Calendar(every.to_string())) +} + +/// Everything that varies between the user-mode and system-mode unit pair. +pub struct TimerSpec { + /// Absolute path of the ssync binary to run. + pub exec: PathBuf, + /// Config file cleanup runs with (embedded in `ExecStart`). + pub config_path: PathBuf, + /// Extra `cleanup` arguments (`--keep 90d`, `--unnamed`, `--agent pi`); + /// every token already validated unit-safe. + pub cleanup_args: Vec, + /// The agents' session dirs — all the sandbox may write. + pub session_dirs: Vec, + /// `Some(name)` renders a system unit with `User=`; `None` a user unit. + pub user: Option, + pub schedule: Schedule, +} + +pub fn validate_spec(spec: &TimerSpec) -> Result<()> { + ensure_unit_safe(&spec.exec.display().to_string(), "binary path")?; + ensure_unit_safe(&spec.config_path.display().to_string(), "config path")?; + for a in &spec.cleanup_args { + ensure_unit_safe(a, "cleanup argument")?; + } + for p in &spec.session_dirs { + ensure_unit_safe(&p.display().to_string(), "session dir")?; + } + if let Some(u) = &spec.user { + ensure_unit_safe(u, "user name")?; + } + if let Schedule::Calendar(expr) = &spec.schedule { + // spaces are legal in OnCalendar; only quoting/specifier chars are not + ensure!( + !expr.contains(['%', '"', '\\', '\n']), + "schedule `{expr}` contains characters a systemd unit cannot carry" + ); + } + Ok(()) +} + +/// Render `ssync-cleanup.service`: a oneshot under the same hardening set as +/// the daemon unit, sandboxed to the session dirs (cleanup only deletes local +/// files; the running daemon tombstones and propagates). +pub fn render_service(spec: &TimerSpec) -> String { + let user = spec + .user + .as_deref() + .map(|u| format!("User={u}\n")) + .unwrap_or_default(); + let args = spec + .cleanup_args + .iter() + .map(|a| format!(" {a}")) + .collect::(); + let rw_paths = spec + .session_dirs + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(" "); + format!( + "[Unit]\n\ + Description=ssync scheduled session cleanup\n\ + After=ssync.service\n\ + \n\ + [Service]\n\ + Type=oneshot\n\ + ExecStart=\"{exec}\" --config \"{config}\" cleanup{args} --apply\n\ + {user}\ + ReadWritePaths={rw_paths}\n\ + {HARDENING}", + exec = spec.exec.display(), + config = spec.config_path.display(), + ) +} + +/// Render `ssync-cleanup.timer`. +pub fn render_timer(spec: &TimerSpec) -> String { + let trigger = match &spec.schedule { + Schedule::Calendar(expr) => format!("OnCalendar={expr}\nPersistent=true\n"), + Schedule::EveryDays(n) => format!("OnBootSec=15min\nOnUnitActiveSec={n}d\n"), + }; + format!( + "[Unit]\n\ + Description=ssync scheduled session cleanup timer\n\ + \n\ + [Timer]\n\ + {trigger}\ + RandomizedDelaySec=1h\n\ + \n\ + [Install]\n\ + WantedBy=timers.target\n" + ) +} + +/// The `cleanup` arguments the timer runs with. `--keep` defaults to 90d +/// unless the invocation is unnamed-only (then age is not a criterion). +pub fn cleanup_args_of( + keep: Option, + unnamed: bool, + agent: Option, +) -> Result> { + let keep = match keep { + Some(k) => Some(k), + None if unnamed => None, + None => Some("90d".to_string()), + }; + let mut args = Vec::new(); + if let Some(k) = &keep { + ssync_core::cleanup::parse_keep(k)?; + args.extend(["--keep".to_string(), k.clone()]); + } + if unnamed { + args.push("--unnamed".to_string()); + } + if let Some(a) = agent { + args.extend(["--agent".to_string(), a]); + } + Ok(args) +} + +/// Reject a calendar expression systemd cannot parse — better one loud +/// enable error than a timer that never fires. +fn validate_calendar(expr: &str) -> Result<()> { + let out = Command::new("systemd-analyze") + .args(["calendar", expr]) + .output() + .context("running systemd-analyze (is systemd available?)")?; + ensure!( + out.status.success(), + "invalid calendar expression `{expr}`: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +pub fn cmd_enable( + config_path: &Path, + config_explicit: bool, + every: String, + keep: Option, + unnamed: bool, + agent: Option, + user: Option, +) -> Result<()> { + let user_mode = !is_root()?; + // same mode rules as `ssync service install` — see cmd_service_install + if user_mode { + ensure!( + user.is_none(), + "--user only applies to system units; run as root to install them" + ); + } else { + ensure!( + user.is_some(), + "system units need an explicit --user to run cleanup as \ + (sessions are per-user)" + ); + ensure!( + config_explicit, + "system mode needs an explicit --config: root's default config path \ + is not readable by the service user" + ); + let raw = fs::read_to_string(config_path) + .with_context(|| format!("reading {}", config_path.display()))?; + ensure!( + !config_uses_tilde(&raw), + "system-mode config must use absolute paths: `~/` expands to root's \ + home at install time but to --user's home in the unit" + ); + } + + let config = Config::load(config_path) + .with_context(|| format!("loading {} (run `ssync init` first)", config_path.display()))?; + if let Some(a) = &agent { + ensure!( + config.agents.iter().any(|c| c.agent == *a), + "agent {a:?} is not in the config" + ); + } + let schedule = schedule_of(&every)?; + if let Schedule::Calendar(expr) = &schedule { + validate_calendar(expr)?; + } + let cleanup_args = cleanup_args_of(keep, unnamed, agent)?; + + // resolve everything fallible before touching the filesystem + let exec = std::env::current_exe() + .and_then(|p| p.canonicalize()) + .context("resolving the ssync binary path")?; + let config_abs = config_path + .canonicalize() + .with_context(|| format!("resolving {}", config_path.display()))?; + let service_user = match &user { + Some(name) => Some(service_user_of(name)?), + None => None, + }; + if let Some(who) = &service_user { + check_access(&exec, who, 0o5)?; + check_access(&config_abs, who, 0o4)?; + } + + // ReadWritePaths requires the session dirs to exist at unit start + let session_dirs: Vec = config + .agents + .iter() + .map(|a| a.session_dir.clone()) + .collect(); + create_write_paths(&session_dirs, service_user.as_ref())?; + + let spec = TimerSpec { + exec, + config_path: config_abs, + cleanup_args, + session_dirs, + user, + schedule, + }; + validate_spec(&spec)?; + + let dir = unit_dir(user_mode)?; + fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + for (name, text) in [ + (SERVICE_NAME, render_service(&spec)), + (TIMER_NAME, render_timer(&spec)), + ] { + let path = dir.join(name); + fs::write(&path, text).with_context(|| format!("writing {}", path.display()))?; + } + + systemctl(user_mode, &["daemon-reload"])?; + systemctl(user_mode, &["enable", "--now", TIMER_NAME])?; + + let scope = if user_mode { "--user " } else { "" }; + println!("installed and started {TIMER_NAME}"); + println!( + "cleanup deletes sessions on EVERY machine (the daemon propagates \ + deletions); one machine with a timer is enough" + ); + println!("check: systemctl {scope}list-timers {TIMER_NAME}"); + Ok(()) +} + +pub fn cmd_disable() -> Result<()> { + let user_mode = !is_root()?; + let dir = unit_dir(user_mode)?; + if !dir.join(TIMER_NAME).exists() { + bail!( + "nothing to disable: {} does not exist", + dir.join(TIMER_NAME).display() + ); + } + // best effort, separately: a masked or never-enabled timer must not + // block removal + for args in [&["stop", TIMER_NAME], &["disable", TIMER_NAME]] { + if let Err(e) = systemctl(user_mode, args) { + eprintln!("ssync: {e}"); + } + } + for name in [TIMER_NAME, SERVICE_NAME] { + let path = dir.join(name); + if path.exists() { + fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; + } + } + systemctl(user_mode, &["daemon-reload"])?; + println!("removed {TIMER_NAME}"); + Ok(()) +} + +pub fn cmd_status() -> Result<()> { + let user_mode = !is_root()?; + if !unit_dir(user_mode)?.join(TIMER_NAME).exists() { + println!("cleanup timer not installed"); + return Ok(()); + } + let mut cmd = Command::new("systemctl"); + if user_mode { + cmd.arg("--user"); + } + // `status` exits non-zero for an inactive unit; its output is the answer + cmd.args(["--no-pager", "status", TIMER_NAME]) + .status() + .context("running systemctl (is systemd available?)")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn spec(schedule: Schedule, user: Option<&str>) -> TimerSpec { + TimerSpec { + exec: PathBuf::from("/usr/local/bin/ssync"), + config_path: PathBuf::from("/home/alice/.config/ssync/config.toml"), + cleanup_args: vec!["--keep".into(), "90d".into()], + session_dirs: vec![ + PathBuf::from("/home/alice/.pi/agent/sessions"), + PathBuf::from("/home/alice/.omp/agent/sessions"), + ], + user: user.map(String::from), + schedule, + } + } + + #[test] + fn schedule_maps_expressible_durations_to_calendar() { + assert_eq!( + schedule_of("1d").unwrap(), + Schedule::Calendar("daily".into()) + ); + assert_eq!( + schedule_of("7d").unwrap(), + Schedule::Calendar("weekly".into()) + ); + assert_eq!( + schedule_of("1w").unwrap(), + Schedule::Calendar("weekly".into()) + ); + } + + #[test] + fn schedule_maps_other_durations_to_monotonic() { + assert_eq!(schedule_of("2d").unwrap(), Schedule::EveryDays(2)); + assert_eq!(schedule_of("2w").unwrap(), Schedule::EveryDays(14)); + } + + #[test] + fn schedule_passes_calendar_expressions_through() { + assert_eq!( + schedule_of("weekly").unwrap(), + Schedule::Calendar("weekly".into()) + ); + assert_eq!( + schedule_of("*-*-* 03:00:00").unwrap(), + Schedule::Calendar("*-*-* 03:00:00".into()) + ); + } + + #[test] + fn schedule_rejects_zero_and_empty() { + assert!(schedule_of("0d").is_err()); + assert!(schedule_of("").is_err()); + assert!(schedule_of(" ").is_err()); + } + + #[test] + fn cleanup_args_default_retention_is_90d() { + assert_eq!( + cleanup_args_of(None, false, None).unwrap(), + vec!["--keep", "90d"] + ); + } + + #[test] + fn cleanup_args_unnamed_only_skips_retention() { + assert_eq!( + cleanup_args_of(None, true, None).unwrap(), + vec!["--unnamed"] + ); + } + + #[test] + fn cleanup_args_combine_all_selectors() { + assert_eq!( + cleanup_args_of(Some("30d".into()), true, Some("pi".into())).unwrap(), + vec!["--keep", "30d", "--unnamed", "--agent", "pi"] + ); + } + + #[test] + fn cleanup_args_reject_bad_keep() { + assert!(cleanup_args_of(Some("soon".into()), false, None).is_err()); + } + + #[test] + fn service_unit_runs_oneshot_cleanup_with_apply() { + let unit = render_service(&spec(Schedule::Calendar("weekly".into()), None)); + assert!(unit.contains("Type=oneshot\n")); + assert!(unit.contains( + "ExecStart=\"/usr/local/bin/ssync\" --config \ + \"/home/alice/.config/ssync/config.toml\" cleanup --keep 90d --apply\n" + )); + assert!(unit.contains("After=ssync.service\n")); + assert!(unit.contains( + "ReadWritePaths=/home/alice/.pi/agent/sessions /home/alice/.omp/agent/sessions\n" + )); + // hardening parity with the daemon unit + assert!(unit.contains("ProtectSystem=strict\n")); + assert!(!unit.contains("User=")); + } + + #[test] + fn system_service_unit_carries_user() { + let unit = render_service(&spec(Schedule::Calendar("weekly".into()), Some("alice"))); + assert!(unit.contains("User=alice\n")); + } + + #[test] + fn calendar_timer_is_persistent() { + let unit = render_timer(&spec(Schedule::Calendar("weekly".into()), None)); + assert!(unit.contains("OnCalendar=weekly\n")); + assert!(unit.contains("Persistent=true\n")); + assert!(unit.contains("RandomizedDelaySec=1h\n")); + assert!(unit.contains("WantedBy=timers.target\n")); + assert!(!unit.contains("OnUnitActiveSec")); + } + + #[test] + fn monotonic_timer_fires_on_uptime() { + let unit = render_timer(&spec(Schedule::EveryDays(2), None)); + assert!(unit.contains("OnBootSec=15min\n")); + assert!(unit.contains("OnUnitActiveSec=2d\n")); + assert!(!unit.contains("Persistent=")); + } + + #[test] + fn validate_rejects_unit_hostile_values() { + let mut s = spec(Schedule::Calendar("weekly".into()), None); + s.cleanup_args = vec!["--agent".into(), "a b".into()]; + assert!(validate_spec(&s).is_err()); + + let mut s = spec(Schedule::Calendar("%i daily".into()), None); + s.cleanup_args.clear(); + assert!(validate_spec(&s).is_err()); + } + + #[test] + fn validate_allows_spaces_in_calendar_expressions() { + let s = spec(Schedule::Calendar("*-*-* 03:00:00".into()), None); + assert!(validate_spec(&s).is_ok()); + } +} diff --git a/crates/ssync/src/main.rs b/crates/ssync/src/main.rs index 00d25b5..eadd8a6 100644 --- a/crates/ssync/src/main.rs +++ b/crates/ssync/src/main.rs @@ -1,5 +1,6 @@ // ssync — p2p sync of coding-agent session files. See docs/DECISIONS.md. +mod cleanup_timer; mod service; use std::path::{Path, PathBuf}; @@ -61,6 +62,12 @@ enum Command { #[arg(long)] apply: bool, }, + /// Manage a systemd timer running `ssync cleanup --apply` on a schedule. + /// Deletions propagate to every peer: one machine's timer prunes all. + CleanupTimer { + #[command(subcommand)] + action: CleanupTimerAction, + }, /// Install or remove a hardened systemd unit running the daemon. Service { #[command(subcommand)] @@ -84,6 +91,33 @@ enum ServiceAction { Uninstall, } +#[derive(Subcommand)] +enum CleanupTimerAction { + /// Install and start the timer/service pair (user units; system as root). + Enable { + /// Schedule: `2d`, `7d`, `weekly`, or a raw systemd calendar expression. + #[arg(long)] + every: String, + /// Delete sessions older than this (e.g. 30d, 6w, 3m, 1y). Defaults + /// to 90d unless --unnamed is the only selector. + #[arg(long)] + keep: Option, + /// Also delete sessions whose title record is present but empty. + #[arg(long)] + unnamed: bool, + /// Only this agent's sessions (default: all configured agents). + #[arg(long)] + agent: Option, + /// Run cleanup as this user (root-only; user units need none). + #[arg(long)] + user: Option, + }, + /// Stop the timer, delete both units, and reload systemd. + Disable, + /// Show the timer's systemd status. + Status, +} + #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); @@ -112,6 +146,25 @@ async fn main() -> Result<()> { } ServiceAction::Uninstall => service::cmd_service_uninstall(), }, + Command::CleanupTimer { action } => match action { + CleanupTimerAction::Enable { + every, + keep, + unnamed, + agent, + user, + } => cleanup_timer::cmd_enable( + &config_path, + config_explicit, + every, + keep, + unnamed, + agent, + user, + ), + CleanupTimerAction::Disable => cleanup_timer::cmd_disable(), + CleanupTimerAction::Status => cleanup_timer::cmd_status(), + }, Command::KeygenNode { path } => { let bytes = ssync_net::generate_key_bytes(); write_secret_bytes(&path, &bytes)?; diff --git a/crates/ssync/src/service.rs b/crates/ssync/src/service.rs index c4a600a..21acb5d 100644 --- a/crates/ssync/src/service.rs +++ b/crates/ssync/src/service.rs @@ -30,7 +30,7 @@ pub struct ServiceSpec { /// `RuntimeDirectory` for SecretFile, read access to the secrets it is /// pointed at, and outbound QUIC/UDP plus netlink for iroh; everything else /// is denied. -const HARDENING: &str = "\ +pub(crate) const HARDENING: &str = "\ NoNewPrivileges=yes ProtectSystem=strict ProtectHome=read-only @@ -61,19 +61,21 @@ SystemCallArchitectures=native UMask=0077 "; -/// Refuse specs a unit file cannot carry verbatim: systemd expands `%` +/// Refuse a value a unit file cannot carry verbatim: systemd expands `%` /// specifiers in every interpolated setting, splits `ReadWritePaths` on /// whitespace, and `"`/`\` alter its quoting — better one loud install /// error than a unit that silently means something else. +pub(crate) fn ensure_unit_safe(value: &str, what: &str) -> Result<()> { + ensure!( + !value.contains(['%', '"', '\\']) && !value.chars().any(|c| c.is_whitespace()), + "{what} `{value}` contains characters a systemd unit cannot carry \ + (whitespace, %, \", \\)" + ); + Ok(()) +} + pub fn validate_spec(spec: &ServiceSpec) -> Result<()> { - let check = |value: &str, what: &str| -> Result<()> { - ensure!( - !value.contains(['%', '"', '\\']) && !value.chars().any(|c| c.is_whitespace()), - "{what} `{value}` contains characters a systemd unit cannot carry \ - (whitespace, %, \", \\)" - ); - Ok(()) - }; + let check = ensure_unit_safe; check(&spec.exec.display().to_string(), "binary path")?; check(&spec.config_path.display().to_string(), "config path")?; for p in &spec.read_write_paths { @@ -137,21 +139,21 @@ pub fn render_unit(spec: &ServiceSpec) -> String { ) } -/// Where the user-manager unit lives (`$XDG_CONFIG_HOME/systemd/user/`). -pub fn user_unit_path(config_dir: &Path) -> PathBuf { - config_dir.join("systemd/user/ssync.service") +/// Where the user-manager units live (`$XDG_CONFIG_HOME/systemd/user/`). +pub fn user_unit_dir(config_dir: &Path) -> PathBuf { + config_dir.join("systemd/user") } -/// The unit file the current invocation manages. -fn unit_path_for(user_mode: bool) -> Result { +/// The directory whose units the current invocation manages. +pub(crate) fn unit_dir(user_mode: bool) -> Result { Ok(if user_mode { - user_unit_path(&dirs::config_dir().context("no config dir")?) + user_unit_dir(&dirs::config_dir().context("no config dir")?) } else { - PathBuf::from(SYSTEM_UNIT_PATH) + PathBuf::from(SYSTEM_UNIT_DIR) }) } -const SYSTEM_UNIT_PATH: &str = "/etc/systemd/system/ssync.service"; +const SYSTEM_UNIT_DIR: &str = "/etc/systemd/system"; const UNIT_NAME: &str = "ssync.service"; /// System-mode configs must not use `~/` paths: install expands them with @@ -164,7 +166,7 @@ pub fn config_uses_tilde(raw: &str) -> bool { /// Effective uid, via the ownership of this process's own /proc entry /// (std has no geteuid, and the workspace forbids unsafe/libc). -fn is_root() -> Result { +pub(crate) fn is_root() -> Result { use std::os::unix::fs::MetadataExt; let meta = fs::metadata("/proc/self").context("reading /proc/self (uid check)")?; Ok(meta.uid() == 0) @@ -201,7 +203,7 @@ fn daemon_path(search_path: &str) -> Result<(String, Vec)> { Ok((path, bins)) } -fn systemctl(user_mode: bool, args: &[&str]) -> Result<()> { +pub(crate) fn systemctl(user_mode: bool, args: &[&str]) -> Result<()> { let mut cmd = Command::new("systemctl"); if user_mode { cmd.arg("--user"); @@ -242,13 +244,13 @@ fn missing_components(p: &Path) -> Vec { /// Identity the system-mode daemon runs as, resolved via `id` (std has no /// passwd lookup). `groups` includes the primary gid. -struct ServiceUser { +pub(crate) struct ServiceUser { uid: u32, gid: u32, groups: Vec, } -fn service_user_of(user: &str) -> Result { +pub(crate) fn service_user_of(user: &str) -> Result { let lookup = |flag: &str| -> Result { let out = Command::new("id") .args([flag, user]) @@ -294,7 +296,7 @@ fn mode_allows(meta: &fs::Metadata, who: &ServiceUser, bits: u32) -> bool { /// (x) on every ancestor, `leaf_bits` on the leaf. A root install resolving /// root-only locations (0700 /root, nix profiles) otherwise becomes a /// runtime crash-loop under `User=`. -fn check_access(path: &Path, who: &ServiceUser, leaf_bits: u32) -> Result<()> { +pub(crate) fn check_access(path: &Path, who: &ServiceUser, leaf_bits: u32) -> Result<()> { let mut cur = Some(path); while let Some(p) = cur { let meta = @@ -315,7 +317,7 @@ fn check_access(path: &Path, who: &ServiceUser, leaf_bits: u32) -> Result<()> { /// Create the write paths 0700. A root (system-mode) install chowns every /// component it created to the service user — the daemon runs as `User=` and /// root-owned dirs would fail it on first start. -fn create_write_paths(paths: &[PathBuf], owner: Option<&ServiceUser>) -> Result<()> { +pub(crate) fn create_write_paths(paths: &[PathBuf], owner: Option<&ServiceUser>) -> Result<()> { use std::os::unix::fs::DirBuilderExt; for p in paths { let created = missing_components(p); @@ -410,7 +412,7 @@ pub fn cmd_service_install( }; validate_spec(&spec)?; - let unit_path = unit_path_for(user_mode)?; + let unit_path = unit_dir(user_mode)?.join(UNIT_NAME); if let Some(parent) = unit_path.parent() { fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } @@ -446,7 +448,7 @@ pub fn cmd_service_install( pub fn cmd_service_uninstall() -> Result<()> { let user_mode = !is_root()?; - let unit_path = unit_path_for(user_mode)?; + let unit_path = unit_dir(user_mode)?.join(UNIT_NAME); if !unit_path.exists() { bail!( "nothing to uninstall: {} does not exist", @@ -552,10 +554,10 @@ mod tests { } #[test] - fn user_unit_path_is_under_the_user_manager_dir() { + fn user_unit_dir_is_under_the_user_manager_dir() { assert_eq!( - user_unit_path(Path::new("/home/alice/.config")), - PathBuf::from("/home/alice/.config/systemd/user/ssync.service") + user_unit_dir(Path::new("/home/alice/.config")), + PathBuf::from("/home/alice/.config/systemd/user") ); } diff --git a/docs/setup.md b/docs/setup.md index 9a9c674..37f257a 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -239,3 +239,45 @@ them on any machine. ssync status # namespace, session count, conflict count ssync conflicts # list sessions that diverged across machines ``` + +## Automatic cleanup + +`ssync cleanup` prunes old sessions but is manual. To schedule it, either use +the CLI (plain-binary deployments) or the nix module option. + +**Deletions propagate mesh-wide.** Cleanup deletes local files; the daemon +tombstones them and every peer deletes its copy. A timer on *one* machine +prunes *all* machines — that is the feature. One machine with a timer is +enough; enabling it on several is harmless (selection is idempotent) but +redundant. The wipe guard still applies: a run that would delete *all* of an +agent's sessions (e.g. an empty or unmounted session dir) refuses instead. + +With the CLI, `cleanup-timer enable` installs and starts a systemd +timer/service pair running `ssync cleanup --apply` (user units; system units +as root with `--user`/`--config`, same rules as `ssync service install`): + +```bash +ssync cleanup-timer enable --every weekly # delete sessions older than 90d +ssync cleanup-timer enable --every 2d --keep 30d # every 2 days, 30d retention +ssync cleanup-timer enable --every weekly --unnamed # only untitled sessions, any age +ssync cleanup-timer status +ssync cleanup-timer disable +``` + +`--every` accepts `2d`/`7d` style periods, or a raw systemd calendar +expression (`weekly`, `*-*-* 03:00:00`). Calendar schedules catch up after +downtime (`Persistent=true`); day periods that `OnCalendar` cannot express run +on unit uptime instead. `--keep` defaults to `90d` unless `--unnamed` is the +only selector. Non-systemd platforms: the cleanup command is non-interactive, +so a cron line works — `0 3 * * 0 ssync cleanup --keep 90d --apply`. + +With the NixOS or home-manager module: + +```nix +services.ssync.autoCleanup = { + enable = true; + schedule = "weekly"; # systemd OnCalendar expression + keep = "90d"; # null to select by `unnamed` only + unnamed = false; # also delete untitled sessions +}; +``` diff --git a/nix/hm-module.nix b/nix/hm-module.nix index 947a4eb..749c5ae 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -27,6 +27,55 @@ let session_dir = "${a.sessionDir}" '') cfg.agents ); + cleanupArgs = lib.concatStringsSep " " ( + lib.optionals (cfg.autoCleanup.keep != null) [ + "--keep" + cfg.autoCleanup.keep + ] + ++ lib.optional cfg.autoCleanup.unnamed "--unnamed" + ); + # --- hardening (parity with the NixOS module and `ssync service install`, + # crates/ssync/src/service.rs — change all three together) --- + # The daemon needs: RW to the session dirs and dataDir (both under + # $HOME), the RuntimeDirectory for age key temp files, read access to + # the secrets it is pointed at, and outbound QUIC/UDP plus netlink for + # iroh. Everything else is denied. Sandboxing in user units needs + # unprivileged user namespaces (default on NixOS; some distros restrict). + # The cleanup oneshot reuses the same set (it only deletes session files). + hardening = { + ReadWritePaths = (map (a: a.sessionDir) cfg.agents) ++ [ cfg.dataDir ]; + RuntimeDirectory = "ssync"; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + PrivateTmp = true; + PrivateDevices = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX AF_NETLINK"; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RemoveIPC = true; + CapabilityBoundingSet = ""; + AmbientCapabilities = ""; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "~@resources" + ]; + SystemCallErrorNumber = "EPERM"; + SystemCallArchitectures = "native"; + UMask = "0077"; + }; in { options.services.ssync = { @@ -98,9 +147,50 @@ in defaultText = lib.literalExpression ''"''${config.xdg.dataHome}/ssync"''; description = "ssync's own managed state (node key, blobs, docs, index)."; }; + + autoCleanup = { + enable = lib.mkEnableOption '' + scheduled session cleanup. Deletions propagate MESH-WIDE: the daemon + tombstones every pruned session and all peers delete their copies, so + a timer on one machine prunes all machines (enabling it on several is + harmless but redundant) + ''; + + schedule = lib.mkOption { + type = lib.types.str; + default = "weekly"; + example = "*-*-* 03:00:00"; + description = "systemd `OnCalendar` expression for the cleanup timer."; + }; + + keep = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "90d"; + description = '' + Delete sessions created more than this long ago (`ssync cleanup + --keep`; `` + `d`/`w`/`m`/`y`). Null to select by `unnamed` only. + ''; + }; + + unnamed = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Also delete sessions whose title record is present but empty + (combines with `keep` as AND). + ''; + }; + }; }; config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = !cfg.autoCleanup.enable || cfg.autoCleanup.keep != null || cfg.autoCleanup.unnamed; + message = "services.ssync.autoCleanup selects nothing: set keep or unnamed"; + } + ]; + # ReadWritePaths requires the paths to exist at unit start. systemd.user.tmpfiles.rules = map (d: "d \"${d}\" 0700 - - -") ( (map (a: a.sessionDir) cfg.agents) ++ [ cfg.dataDir ] @@ -125,47 +215,33 @@ in # point it at the unit's own (writable) RuntimeDirectory. "XDG_RUNTIME_DIR=%t/ssync" ]; + } + // hardening; + }; - # --- hardening (parity with the NixOS module and `ssync service install`, - # crates/ssync/src/service.rs — change all three together) --- - # The daemon needs: RW to the session dirs and dataDir (both under - # $HOME), the RuntimeDirectory for age key temp files, read access to - # the secrets it is pointed at, and outbound QUIC/UDP plus netlink for - # iroh. Everything else is denied. Sandboxing in user units needs - # unprivileged user namespaces (default on NixOS; some distros restrict). - ReadWritePaths = (map (a: a.sessionDir) cfg.agents) ++ [ cfg.dataDir ]; - RuntimeDirectory = "ssync"; - NoNewPrivileges = true; - ProtectSystem = "strict"; - ProtectHome = "read-only"; - PrivateTmp = true; - PrivateDevices = true; - ProtectClock = true; - ProtectHostname = true; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectKernelLogs = true; - ProtectControlGroups = true; - ProtectProc = "invisible"; - ProcSubset = "pid"; - RestrictNamespaces = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - RestrictAddressFamilies = "AF_INET AF_INET6 AF_UNIX AF_NETLINK"; - LockPersonality = true; - MemoryDenyWriteExecute = true; - RemoveIPC = true; - CapabilityBoundingSet = ""; - AmbientCapabilities = ""; - SystemCallFilter = [ - "@system-service" - "~@privileged" - "~@resources" - ]; - SystemCallErrorNumber = "EPERM"; - SystemCallArchitectures = "native"; - UMask = "0077"; + # scheduled cleanup: prune old sessions via the plain cleanup CLI; the + # running daemon tombstones the deletions and every peer follows. + systemd.user.services.ssync-cleanup = lib.mkIf cfg.autoCleanup.enable { + Unit = { + Description = "ssync scheduled session cleanup"; + After = [ "ssync.service" ]; + }; + Service = { + Type = "oneshot"; + ExecStart = "${cfg.package}/bin/ssync --config ${configFile} cleanup ${cleanupArgs} --apply"; + } + // hardening; + }; + + systemd.user.timers.ssync-cleanup = lib.mkIf cfg.autoCleanup.enable { + Unit.Description = "ssync scheduled session cleanup timer"; + Timer = { + OnCalendar = cfg.autoCleanup.schedule; + # a machine that slept through the window catches up on next login + Persistent = true; + RandomizedDelaySec = "1h"; }; + Install.WantedBy = [ "timers.target" ]; }; }; } diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix index 0bf1dae..3c26ba9 100644 --- a/nix/nixos-module.nix +++ b/nix/nixos-module.nix @@ -35,6 +35,57 @@ let session_dir = "${a.sessionDir}" '') cfg.agents ); + cleanupArgs = lib.concatStringsSep " " ( + lib.optionals (cfg.autoCleanup.keep != null) [ + "--keep" + cfg.autoCleanup.keep + ] + ++ lib.optional cfg.autoCleanup.unnamed "--unnamed" + ); + # --- hardening (parity with the home-manager module and `ssync service + # install`, crates/ssync/src/service.rs — change all three together) --- + # The daemon needs: RW to the session dirs (under $HOME) and its StateDirectory, + # read access to the secrets it is pointed at (/run/secrets, /nix/store), + # and outbound QUIC/UDP plus netlink for iroh. Everything else is denied. + # The cleanup oneshot reuses the same set (it only deletes session files). + hardening = { + ReadWritePaths = map (a: a.sessionDir) cfg.agents; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + PrivateTmp = true; + PrivateDevices = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectControlGroups = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + "AF_NETLINK" + ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + RemoveIPC = true; + CapabilityBoundingSet = ""; + AmbientCapabilities = ""; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "~@resources" + ]; + SystemCallErrorNumber = "EPERM"; + SystemCallArchitectures = "native"; + UMask = "0077"; + }; in { options.services.ssync = { @@ -135,9 +186,50 @@ in shared-identity mode. The clan service fills these in. ''; }; + + autoCleanup = { + enable = lib.mkEnableOption '' + scheduled session cleanup. Deletions propagate MESH-WIDE: the daemon + tombstones every pruned session and all peers delete their copies, so + a timer on one machine prunes all machines (enabling it on several is + harmless but redundant) + ''; + + schedule = lib.mkOption { + type = lib.types.str; + default = "weekly"; + example = "*-*-* 03:00:00"; + description = "systemd `OnCalendar` expression for the cleanup timer."; + }; + + keep = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = "90d"; + description = '' + Delete sessions created more than this long ago (`ssync cleanup + --keep`; `` + `d`/`w`/`m`/`y`). Null to select by `unnamed` only. + ''; + }; + + unnamed = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Also delete sessions whose title record is present but empty + (combines with `keep` as AND). + ''; + }; + }; }; config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = !cfg.autoCleanup.enable || cfg.autoCleanup.keep != null || cfg.autoCleanup.unnamed; + message = "services.ssync.autoCleanup selects nothing: set keep or unnamed"; + } + ]; + # ensure the watched session dirs exist so the sandbox's ReadWritePaths bind # succeeds on first boot (owner cfg.user, 0700). systemd.tmpfiles.rules = map (a: "d ${a.sessionDir} 0700 ${cfg.user} - - -") cfg.agents; @@ -156,48 +248,30 @@ in # cap glibc malloc arenas: transient session read/encrypt buffers across # tokio workers otherwise pin the peak-import high-water mark as RSS. Environment = [ "MALLOC_ARENA_MAX=2" ]; + } + // hardening; + }; + + # scheduled cleanup: prune old sessions via the plain cleanup CLI; the + # running daemon tombstones the deletions and every peer follows. + systemd.services.ssync-cleanup = lib.mkIf cfg.autoCleanup.enable { + description = "ssync scheduled session cleanup"; + after = [ "ssync.service" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${cfg.package}/bin/ssync --config ${configFile} cleanup ${cleanupArgs} --apply"; + User = cfg.user; + } + // hardening; + }; - # --- hardening (parity with the home-manager module and `ssync service - # install`, crates/ssync/src/service.rs — change all three together) --- - # The daemon needs: RW to the session dirs (under $HOME) and its StateDirectory, - # read access to the secrets it is pointed at (/run/secrets, /nix/store), - # and outbound QUIC/UDP plus netlink for iroh. Everything else is denied. - ReadWritePaths = map (a: a.sessionDir) cfg.agents; - NoNewPrivileges = true; - ProtectSystem = "strict"; - ProtectHome = "read-only"; - PrivateTmp = true; - PrivateDevices = true; - ProtectClock = true; - ProtectHostname = true; - ProtectKernelTunables = true; - ProtectKernelModules = true; - ProtectKernelLogs = true; - ProtectControlGroups = true; - ProtectProc = "invisible"; - ProcSubset = "pid"; - RestrictNamespaces = true; - RestrictRealtime = true; - RestrictSUIDSGID = true; - RestrictAddressFamilies = [ - "AF_INET" - "AF_INET6" - "AF_UNIX" - "AF_NETLINK" - ]; - LockPersonality = true; - MemoryDenyWriteExecute = true; - RemoveIPC = true; - CapabilityBoundingSet = ""; - AmbientCapabilities = ""; - SystemCallFilter = [ - "@system-service" - "~@privileged" - "~@resources" - ]; - SystemCallErrorNumber = "EPERM"; - SystemCallArchitectures = "native"; - UMask = "0077"; + systemd.timers.ssync-cleanup = lib.mkIf cfg.autoCleanup.enable { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = cfg.autoCleanup.schedule; + # a machine that slept through the window catches up on next boot + Persistent = true; + RandomizedDelaySec = "1h"; }; }; }; diff --git a/nix/vm-module-test.nix b/nix/vm-module-test.nix index 3780257..0964288 100644 --- a/nix/vm-module-test.nix +++ b/nix/vm-module-test.nix @@ -1,7 +1,9 @@ # Smoke-test the NixOS module: enabling `services.ssync` starts the daemon, which # auto-generates its age key and index namespace and comes up as a service. Also # covers per-machine mode: `recipients` must land in the rendered config and the -# daemon must come up encrypting to itself plus that peer. +# daemon must come up encrypting to itself plus that peer. `autoCleanup` must +# install an enabled timer whose service runs `cleanup --apply` with the +# configured selectors. { pkgs, self, @@ -19,6 +21,11 @@ pkgs.testers.runNixOSTest { # a real (throwaway) peer recipient: per-machine mode; the daemon still # generates its own key and must start normally. recipients = [ "age10p370q7mfmpxpxxxuz765r7ddhcgr25uxthwtfcpd6ylg8mx5pmqt9mkc9" ]; + autoCleanup = { + enable = true; + schedule = "daily"; + unnamed = true; + }; }; }; @@ -29,5 +36,12 @@ pkgs.testers.runNixOSTest { machine.succeed("test -s /var/lib/ssync/ticket") cfg = machine.succeed("systemctl cat ssync | grep -oP '(?<=--config )\S+'").strip() machine.succeed(f"grep -q 'recipients = ' {cfg}") + + machine.wait_for_unit("ssync-cleanup.timer") + machine.succeed("systemctl list-timers ssync-cleanup.timer | grep -q ssync-cleanup") + exec = machine.succeed("systemctl cat ssync-cleanup.service | grep ExecStart").strip() + assert "cleanup --keep 90d --unnamed --apply" in exec, exec + # oneshot must actually run clean (dry selectors, empty session dirs) + machine.succeed("systemctl start ssync-cleanup.service") ''; } From 82cd753aa56973b4b99543944a30771d28a3ec20 Mon Sep 17 00:00:00 2001 From: fosskar <117449098+fosskar@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:27:59 +0200 Subject: [PATCH 2/2] cli: address cleanup-timer review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the cleanup-timer PR found one real hazard and a set of polish items: - hm-module: pull RuntimeDirectory and ReadWritePaths out of the shared hardening set into per-unit Service blocks. systemd does not ref-count runtime dirs shared between units (systemd#5394): every cleanup oneshot exit removed /run/user/$UID/ssync under the running daemon, silently demoting SecretFile from the runtime tmpfs to PrivateTmp. The oneshot also loses its needless dataDir write grant. - cleanup-timer enable: re-check access on pre-existing session dirs after create_write_paths, same as service install — a root-owned leaf otherwise fails only when the timer first fires. - schedule_of: checked_mul on the --every day/week count; an overflowing count is now a loud error instead of a wrap. - --keep help: match the code — the 90d default is skipped whenever --unnamed is given, not only when it is the only selector. - extract ensure_install_mode and remove_units into service.rs, shared by service install/uninstall and cleanup-timer enable/disable. - bundle keep/unnamed/agent into CleanupSelectors; drops the too_many_arguments allow. --- crates/ssync/src/cleanup_timer.rs | 113 +++++++++++++----------------- crates/ssync/src/main.rs | 10 +-- crates/ssync/src/service.rs | 87 +++++++++++++++-------- nix/hm-module.nix | 10 ++- 4 files changed, 119 insertions(+), 101 deletions(-) diff --git a/crates/ssync/src/cleanup_timer.rs b/crates/ssync/src/cleanup_timer.rs index cb59dcd..d4e5e5e 100644 --- a/crates/ssync/src/cleanup_timer.rs +++ b/crates/ssync/src/cleanup_timer.rs @@ -12,8 +12,8 @@ use anyhow::{Context, Result, bail, ensure}; use ssync_core::Config; use crate::service::{ - HARDENING, check_access, config_uses_tilde, create_write_paths, ensure_unit_safe, is_root, - service_user_of, systemctl, unit_dir, + HARDENING, check_access, create_write_paths, ensure_install_mode, ensure_unit_safe, is_root, + remove_units, service_user_of, systemctl, unit_dir, }; const SERVICE_NAME: &str = "ssync-cleanup.service"; @@ -40,9 +40,12 @@ pub fn schedule_of(every: &str) -> Result { every .strip_suffix(suffix) .and_then(|n| n.parse::().ok()) - .map(|n| n * per) + .map(|n| (n, per)) }; - if let Some(n) = days('d', 1).or_else(|| days('w', 7)) { + if let Some((n, per)) = days('d', 1).or_else(|| days('w', 7)) { + let n = n + .checked_mul(per) + .with_context(|| format!("--every {every} is out of range"))?; ensure!(n > 0, "--every {every} never fires"); return Ok(match n { 1 => Schedule::Calendar("daily".into()), @@ -147,16 +150,20 @@ pub fn render_timer(spec: &TimerSpec) -> String { ) } +/// The cleanup selectors the timer runs with (`ssync cleanup-timer enable +/// --keep/--unnamed/--agent`). +pub struct CleanupSelectors { + pub keep: Option, + pub unnamed: bool, + pub agent: Option, +} + /// The `cleanup` arguments the timer runs with. `--keep` defaults to 90d -/// unless the invocation is unnamed-only (then age is not a criterion). -pub fn cleanup_args_of( - keep: Option, - unnamed: bool, - agent: Option, -) -> Result> { - let keep = match keep { +/// unless `--unnamed` is given (then age is not a criterion). +pub fn cleanup_args_of(sel: CleanupSelectors) -> Result> { + let keep = match sel.keep { Some(k) => Some(k), - None if unnamed => None, + None if sel.unnamed => None, None => Some("90d".to_string()), }; let mut args = Vec::new(); @@ -164,10 +171,10 @@ pub fn cleanup_args_of( ssync_core::cleanup::parse_keep(k)?; args.extend(["--keep".to_string(), k.clone()]); } - if unnamed { + if sel.unnamed { args.push("--unnamed".to_string()); } - if let Some(a) = agent { + if let Some(a) = sel.agent { args.extend(["--agent".to_string(), a]); } Ok(args) @@ -188,46 +195,19 @@ fn validate_calendar(expr: &str) -> Result<()> { Ok(()) } -#[allow(clippy::too_many_arguments)] pub fn cmd_enable( config_path: &Path, config_explicit: bool, every: String, - keep: Option, - unnamed: bool, - agent: Option, + selectors: CleanupSelectors, user: Option, ) -> Result<()> { let user_mode = !is_root()?; - // same mode rules as `ssync service install` — see cmd_service_install - if user_mode { - ensure!( - user.is_none(), - "--user only applies to system units; run as root to install them" - ); - } else { - ensure!( - user.is_some(), - "system units need an explicit --user to run cleanup as \ - (sessions are per-user)" - ); - ensure!( - config_explicit, - "system mode needs an explicit --config: root's default config path \ - is not readable by the service user" - ); - let raw = fs::read_to_string(config_path) - .with_context(|| format!("reading {}", config_path.display()))?; - ensure!( - !config_uses_tilde(&raw), - "system-mode config must use absolute paths: `~/` expands to root's \ - home at install time but to --user's home in the unit" - ); - } + ensure_install_mode(user_mode, user.as_ref(), config_explicit, config_path)?; let config = Config::load(config_path) .with_context(|| format!("loading {} (run `ssync init` first)", config_path.display()))?; - if let Some(a) = &agent { + if let Some(a) = &selectors.agent { ensure!( config.agents.iter().any(|c| c.agent == *a), "agent {a:?} is not in the config" @@ -237,7 +217,7 @@ pub fn cmd_enable( if let Schedule::Calendar(expr) = &schedule { validate_calendar(expr)?; } - let cleanup_args = cleanup_args_of(keep, unnamed, agent)?; + let cleanup_args = cleanup_args_of(selectors)?; // resolve everything fallible before touching the filesystem let exec = std::env::current_exe() @@ -262,6 +242,12 @@ pub fn cmd_enable( .map(|a| a.session_dir.clone()) .collect(); create_write_paths(&session_dirs, service_user.as_ref())?; + if let Some(who) = &service_user { + // pre-existing dirs were never chowned; catch a root-owned leaf now + for p in &session_dirs { + check_access(p, who, 0o7)?; + } + } let spec = TimerSpec { exec, @@ -305,20 +291,7 @@ pub fn cmd_disable() -> Result<()> { dir.join(TIMER_NAME).display() ); } - // best effort, separately: a masked or never-enabled timer must not - // block removal - for args in [&["stop", TIMER_NAME], &["disable", TIMER_NAME]] { - if let Err(e) = systemctl(user_mode, args) { - eprintln!("ssync: {e}"); - } - } - for name in [TIMER_NAME, SERVICE_NAME] { - let path = dir.join(name); - if path.exists() { - fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; - } - } - systemctl(user_mode, &["daemon-reload"])?; + remove_units(user_mode, TIMER_NAME, &[TIMER_NAME, SERVICE_NAME])?; println!("removed {TIMER_NAME}"); Ok(()) } @@ -344,6 +317,14 @@ pub fn cmd_status() -> Result<()> { mod tests { use super::*; + fn sel(keep: Option, unnamed: bool, agent: Option) -> CleanupSelectors { + CleanupSelectors { + keep, + unnamed, + agent, + } + } + fn spec(schedule: Schedule, user: Option<&str>) -> TimerSpec { TimerSpec { exec: PathBuf::from("/usr/local/bin/ssync"), @@ -399,10 +380,16 @@ mod tests { assert!(schedule_of(" ").is_err()); } + #[test] + fn schedule_rejects_overflowing_counts() { + // u64-parseable count whose *7 overflows + assert!(schedule_of("9999999999999999999w").is_err()); + } + #[test] fn cleanup_args_default_retention_is_90d() { assert_eq!( - cleanup_args_of(None, false, None).unwrap(), + cleanup_args_of(sel(None, false, None)).unwrap(), vec!["--keep", "90d"] ); } @@ -410,7 +397,7 @@ mod tests { #[test] fn cleanup_args_unnamed_only_skips_retention() { assert_eq!( - cleanup_args_of(None, true, None).unwrap(), + cleanup_args_of(sel(None, true, None)).unwrap(), vec!["--unnamed"] ); } @@ -418,14 +405,14 @@ mod tests { #[test] fn cleanup_args_combine_all_selectors() { assert_eq!( - cleanup_args_of(Some("30d".into()), true, Some("pi".into())).unwrap(), + cleanup_args_of(sel(Some("30d".into()), true, Some("pi".into()))).unwrap(), vec!["--keep", "30d", "--unnamed", "--agent", "pi"] ); } #[test] fn cleanup_args_reject_bad_keep() { - assert!(cleanup_args_of(Some("soon".into()), false, None).is_err()); + assert!(cleanup_args_of(sel(Some("soon".into()), false, None)).is_err()); } #[test] diff --git a/crates/ssync/src/main.rs b/crates/ssync/src/main.rs index eadd8a6..f9c74f2 100644 --- a/crates/ssync/src/main.rs +++ b/crates/ssync/src/main.rs @@ -99,7 +99,7 @@ enum CleanupTimerAction { #[arg(long)] every: String, /// Delete sessions older than this (e.g. 30d, 6w, 3m, 1y). Defaults - /// to 90d unless --unnamed is the only selector. + /// to 90d unless --unnamed is given. #[arg(long)] keep: Option, /// Also delete sessions whose title record is present but empty. @@ -157,9 +157,11 @@ async fn main() -> Result<()> { &config_path, config_explicit, every, - keep, - unnamed, - agent, + cleanup_timer::CleanupSelectors { + keep, + unnamed, + agent, + }, user, ), CleanupTimerAction::Disable => cleanup_timer::cmd_disable(), diff --git a/crates/ssync/src/service.rs b/crates/ssync/src/service.rs index 21acb5d..1967cdb 100644 --- a/crates/ssync/src/service.rs +++ b/crates/ssync/src/service.rs @@ -342,30 +342,7 @@ pub fn cmd_service_install( user: Option, ) -> Result<()> { let user_mode = !is_root()?; - if user_mode { - ensure!( - user.is_none(), - "--user only applies to a system unit; run as root to install one" - ); - } else { - ensure!( - user.is_some(), - "a system unit needs an explicit --user to run the daemon as \ - (sessions, keys, and watched dirs are per-user)" - ); - ensure!( - config_explicit, - "system mode needs an explicit --config: root's default config path \ - is not readable by the service user" - ); - let raw = fs::read_to_string(config_path) - .with_context(|| format!("reading {}", config_path.display()))?; - ensure!( - !config_uses_tilde(&raw), - "system-mode config must use absolute paths: `~/` expands to root's \ - home at install time but to --user's home in the daemon" - ); - } + ensure_install_mode(user_mode, user.as_ref(), config_explicit, config_path)?; let config = Config::load(config_path) .with_context(|| format!("loading {} (run `ssync init` first)", config_path.display()))?; @@ -455,17 +432,65 @@ pub fn cmd_service_uninstall() -> Result<()> { unit_path.display() ); } - // best effort, separately: a masked or never-enabled unit must not block - // removal, and a failed `disable` must not leave the daemon running - for args in [&["stop", UNIT_NAME], &["disable", UNIT_NAME]] { + remove_units(user_mode, UNIT_NAME, &[UNIT_NAME])?; + println!("removed {}", unit_path.display()); + Ok(()) +} + +/// Shared user/system mode rules for the unit-installing commands +/// (`service install`, `cleanup-timer enable`): user mode takes no `--user`, +/// system mode requires one plus an explicit, tilde-free config. +pub(crate) fn ensure_install_mode( + user_mode: bool, + user: Option<&String>, + config_explicit: bool, + config_path: &Path, +) -> Result<()> { + if user_mode { + ensure!( + user.is_none(), + "--user only applies to system units; run as root to install them" + ); + } else { + ensure!( + user.is_some(), + "system units need an explicit --user to run as \ + (sessions, keys, and watched dirs are per-user)" + ); + ensure!( + config_explicit, + "system mode needs an explicit --config: root's default config path \ + is not readable by the service user" + ); + let raw = fs::read_to_string(config_path) + .with_context(|| format!("reading {}", config_path.display()))?; + ensure!( + !config_uses_tilde(&raw), + "system-mode config must use absolute paths: `~/` expands to root's \ + home at install time but to --user's home in the unit" + ); + } + Ok(()) +} + +/// Best-effort stop/disable of `stop_unit`, remove `files` from the unit +/// dir, daemon-reload. Best effort separately: a masked or never-enabled +/// unit must not block removal, and a failed `disable` must not leave it +/// running. +pub(crate) fn remove_units(user_mode: bool, stop_unit: &str, files: &[&str]) -> Result<()> { + for args in [&["stop", stop_unit], &["disable", stop_unit]] { if let Err(e) = systemctl(user_mode, args) { eprintln!("ssync: {e}"); } } - fs::remove_file(&unit_path).with_context(|| format!("removing {}", unit_path.display()))?; - systemctl(user_mode, &["daemon-reload"])?; - println!("removed {}", unit_path.display()); - Ok(()) + let dir = unit_dir(user_mode)?; + for name in files { + let path = dir.join(name); + if path.exists() { + fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; + } + } + systemctl(user_mode, &["daemon-reload"]) } #[cfg(test)] diff --git a/nix/hm-module.nix b/nix/hm-module.nix index 749c5ae..4891f34 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -41,10 +41,11 @@ let # the secrets it is pointed at, and outbound QUIC/UDP plus netlink for # iroh. Everything else is denied. Sandboxing in user units needs # unprivileged user namespaces (default on NixOS; some distros restrict). - # The cleanup oneshot reuses the same set (it only deletes session files). + # The cleanup oneshot reuses the same set, but path grants are per unit: + # sharing RuntimeDirectory would let the oneshot's exit remove it under + # the running daemon (systemd#5394 — runtime dirs are not ref-counted), + # and the oneshot only deletes session files, so no dataDir write access. hardening = { - ReadWritePaths = (map (a: a.sessionDir) cfg.agents) ++ [ cfg.dataDir ]; - RuntimeDirectory = "ssync"; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = "read-only"; @@ -215,6 +216,8 @@ in # point it at the unit's own (writable) RuntimeDirectory. "XDG_RUNTIME_DIR=%t/ssync" ]; + ReadWritePaths = (map (a: a.sessionDir) cfg.agents) ++ [ cfg.dataDir ]; + RuntimeDirectory = "ssync"; } // hardening; }; @@ -229,6 +232,7 @@ in Service = { Type = "oneshot"; ExecStart = "${cfg.package}/bin/ssync --config ${configFile} cleanup ${cleanupArgs} --apply"; + ReadWritePaths = map (a: a.sessionDir) cfg.agents; } // hardening; };