From 0ed470281b96aeb8604e646937a411f27143efb5 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 20 Sep 2026 16:51:54 +0200 Subject: [PATCH 1/5] =?UTF-8?q?Feature:=20Erg=C3=A4nzt=20info/debug-Loggin?= =?UTF-8?q?g=20im=20gesamten=20Programm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher wurde logger-ctdra nur für warn() bei Fehlerfällen genutzt, wodurch z. B. ein manuelles 'mount --all' im Terminal minutenlang keine Ausgabe zeigte, obwohl im Hintergrund Erreichbarkeitsprüfungen (Ping/curl/mac2ip, bis zu 15s) und Mount-Vorgänge liefen. Jetzt loggen Reconcile-Entscheidungen, Mount-/Unmount-Aufrufe, MAC-Auflösung, Lock-Erwerb, Config-/Credential-/ Crypto-Operationen sowie Cron-/CLI-Lifecycle-Ereignisse durchgängig auf info- bzw. debug-Niveau (ohne Passwörter/Zugangsdaten zu protokollieren). Co-Authored-By: Claude Sonnet 5 --- src/cli/drive.rs | 3 ++ src/cli/mod.rs | 24 +++++++++++ src/cli/mount_cmd.rs | 8 ++++ src/cli/service.rs | 9 +++- src/cli/watch.rs | 12 ++++++ src/config/mod.rs | 4 +- src/config/pairs.rs | 6 +++ src/crypto/key.rs | 8 ++++ src/crypto/mod.rs | 2 + src/db/credentials.rs | 21 ++++++++++ src/db/mod.rs | 1 + src/main.rs | 7 ++++ src/mount/lock.rs | 14 ++++++- src/mount/mod.rs | 34 +++++++++++---- src/mount/smb.rs | 7 +++- src/mount/target.rs | 9 ++++ src/mount/webdav.rs | 4 ++ src/network/mac2ip.rs | 20 ++++++++- src/network/mod.rs | 9 ++++ src/reconcile/mod.rs | 96 +++++++++++++++++++++++++++++++++++++------ src/systemd/mod.rs | 5 +++ 21 files changed, 277 insertions(+), 26 deletions(-) diff --git a/src/cli/drive.rs b/src/cli/drive.rs index 2beeede..e9e1ef5 100644 --- a/src/cli/drive.rs +++ b/src/cli/drive.rs @@ -149,6 +149,7 @@ async fn remove(id: &str) -> anyhow::Result<()> { creds.delete(id, None).await?; smart_mount::mount::cleanup_credentials(&pair, &cfg.settings); + logger_ctdra::info("drive", &format!("drive pair '{id}' removed")); println!("Drive pair '{id}' removed."); Ok(()) } @@ -241,6 +242,7 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { .await?; } + logger_ctdra::info("drive", &format!("drive pair '{id}' created")); println!("Drive pair '{id}' created."); Ok(()) } @@ -373,6 +375,7 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { .await?; } + logger_ctdra::info("drive", &format!("drive pair '{id}' updated")); println!("Drive pair '{id}' updated."); Ok(()) } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 3e5b8a8..f8cd2e3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -20,9 +20,14 @@ use clap_complete::Shell; /// `sudo` nicht gefunden). pub(crate) fn require_root(context: &str) -> anyhow::Result<()> { if !sudo_ctdra::is_run_as_root() { + logger_ctdra::info( + "cli", + &format!("'{context}' requires root - re-executing via sudo"), + ); let err = sudo_ctdra::run_as_root(); anyhow::bail!("'{context}' requires root privileges: could not re-exec via sudo: {err}"); } + logger_ctdra::debug("cli", &format!("'{context}': already running as root")); Ok(()) } @@ -88,8 +93,27 @@ pub enum Commands { Completions { shell: Shell }, } +/// Kurzname eines Subcommands fürs Logging (siehe [`dispatch`]) - kein `Debug`-Derive auf +/// `Commands` nötig, das würde auch die (teils sensiblen) Argument-Felder mit abdrucken. +fn command_label(cmd: &Commands) -> &'static str { + match cmd { + Commands::Drive { .. } => "drive", + Commands::Mount { .. } => "mount", + Commands::Unmount { .. } => "unmount", + Commands::Status { .. } => "status", + Commands::Watch => "watch", + Commands::Service { .. } => "service", + Commands::Doctor { .. } => "doctor", + Commands::Completions { .. } => "completions", + } +} + /// Führt das per `Cli` geparste Subcommand aus. pub async fn dispatch(cli: Cli) -> anyhow::Result<()> { + logger_ctdra::debug( + "cli", + &format!("dispatching '{}'", command_label(&cli.command)), + ); match cli.command { Commands::Drive { action } => drive::run(*action).await, Commands::Mount { name, all } => mount_cmd::run_mount(name, all).await, diff --git a/src/cli/mount_cmd.rs b/src/cli/mount_cmd.rs index 6fad7d5..82e0d77 100644 --- a/src/cli/mount_cmd.rs +++ b/src/cli/mount_cmd.rs @@ -26,6 +26,10 @@ pub async fn run_mount(name: Option, all: bool) -> anyhow::Result<()> { println!("No matching drive pairs found."); return Ok(()); } + logger_ctdra::info( + "mount", + &format!("mount: processing {} drive pair(s)", pairs.len()), + ); let creds = CredentialStore::open().await?; for pair in &pairs { @@ -43,6 +47,10 @@ pub async fn run_unmount(name: Option, all: bool) -> anyhow::Result<()> println!("No matching drive pairs found."); return Ok(()); } + logger_ctdra::info( + "mount", + &format!("unmount: processing {} drive pair(s)", pairs.len()), + ); for pair in &pairs { match reconcile::unmount_pair(pair, &cfg.settings).await { diff --git a/src/cli/service.rs b/src/cli/service.rs index 9539da5..8c36fe1 100644 --- a/src/cli/service.rs +++ b/src/cli/service.rs @@ -31,6 +31,10 @@ fn install() -> anyhow::Result<()> { match systemd::install_cron(interval)? { systemd::CronInstallOutcome::SystemFile(path) => { + logger_ctdra::info( + "service", + &format!("cron entry written: {}", path.display()), + ); println!("Cron entry written: {}", path.display()); } systemd::CronInstallOutcome::Unavailable => { @@ -45,7 +49,10 @@ fn install() -> anyhow::Result<()> { fn uninstall() -> anyhow::Result<()> { match systemd::uninstall_cron()? { - systemd::CronUninstallOutcome::Removed => println!("Cron entry removed."), + systemd::CronUninstallOutcome::Removed => { + logger_ctdra::info("service", "cron entry removed"); + println!("Cron entry removed."); + } systemd::CronUninstallOutcome::NotPresent => { println!("Nothing to remove - no cron entry was installed.") } diff --git a/src/cli/watch.rs b/src/cli/watch.rs index df4e677..8352c87 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -19,6 +19,18 @@ pub async fn run() -> anyhow::Result<()> { had_failure = true; } } + logger_ctdra::info( + "watch", + &format!( + "watch pass done: {} pair(s), {}", + outcomes.len(), + if had_failure { + "with failures" + } else { + "no failures" + } + ), + ); if had_failure { anyhow::bail!("at least one drive pair could not be reconciled"); diff --git a/src/config/mod.rs b/src/config/mod.rs index cbb053e..56ab9c9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -19,5 +19,7 @@ pub use schema::{ pub fn init() { config_ctdra::set_config_name("config"); let program = config_ctdra::get_program_name(); - config_ctdra::set_custom_dir(std::path::PathBuf::from("/etc").join(program)); + let dir = std::path::PathBuf::from("/etc").join(program); + logger_ctdra::debug("config", &format!("config directory: {}", dir.display())); + config_ctdra::set_custom_dir(dir); } diff --git a/src/config/pairs.rs b/src/config/pairs.rs index 63aa67c..5be8d74 100644 --- a/src/config/pairs.rs +++ b/src/config/pairs.rs @@ -10,6 +10,10 @@ pub fn load() -> Result { /// Fügt ein neues Laufwerkspaar hinzu (load-mutate-store in einem atomaren Schritt). pub fn add_pair(pair: DrivePair) -> Result { + logger_ctdra::debug( + "config", + &format!("writing new pair '{}' to config", pair.id), + ); Ok(config_ctdra::modify::(|cfg| { cfg.pairs.push(pair.clone()); })?) @@ -18,6 +22,7 @@ pub fn add_pair(pair: DrivePair) -> Result { /// Ersetzt ein bestehendes Laufwerkspaar (Vergleich über `id`). pub fn update_pair(pair: DrivePair) -> Result { let id = pair.id.clone(); + logger_ctdra::debug("config", &format!("writing updated pair '{id}' to config")); let updated = config_ctdra::modify::(move |cfg| { if let Some(existing) = cfg.pairs.iter_mut().find(|p| p.id == pair.id) { *existing = pair.clone(); @@ -31,6 +36,7 @@ pub fn update_pair(pair: DrivePair) -> Result { /// Entfernt ein Laufwerkspaar per ID. pub fn remove_pair(id: &str) -> Result { + logger_ctdra::debug("config", &format!("removing pair '{id}' from config")); // Die "vorher"-Länge wird INNERHALB desselben `modify`-Aufrufs (auf der bereits frisch // geladenen `cfg`) ermittelt statt über einen separaten, vorgelagerten `load()`-Aufruf: // zwischen zwei getrennten Aufrufen könnte ein nebenläufiger Schreiber die Paarliste diff --git a/src/crypto/key.rs b/src/crypto/key.rs index 6b34aab..13695a6 100644 --- a/src/crypto/key.rs +++ b/src/crypto/key.rs @@ -32,8 +32,16 @@ mod file_key { pub fn load_or_create(path: &Path) -> Result<[u8; 32]> { if path.exists() { + logger_ctdra::debug( + "crypto", + &format!("loading master key from '{}'", path.display()), + ); return read(path); } + logger_ctdra::info( + "crypto", + &format!("creating new master key at '{}'", path.display()), + ); create(path) } diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index a73d600..c4e9ff9 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -29,6 +29,7 @@ pub const NONCE_LEN: usize = 12; /// aes-gcms Re-Export-Kette nicht automatisch aktiviert wird). `getrandom::fill` ist /// unabhängig davon stabil und genau für diesen Zweck gedacht. pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec, Vec)> { + logger_ctdra::debug("crypto", "encrypting credential data"); let cipher = Aes256Gcm::new(&Key::::from(*key)); let mut nonce_bytes = [0u8; NONCE_LEN]; @@ -44,6 +45,7 @@ pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec, Vec)> { /// Entschlüsselt einen zuvor mit [`encrypt`] erzeugten Ciphertext. pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result> { + logger_ctdra::debug("crypto", "decrypting credential data"); if nonce.len() != NONCE_LEN { return Err(Error::Crypto(format!( "invalid nonce length: expected {NONCE_LEN}, got {}", diff --git a/src/db/credentials.rs b/src/db/credentials.rs index 8090314..748f858 100644 --- a/src/db/credentials.rs +++ b/src/db/credentials.rs @@ -58,6 +58,13 @@ impl CredentialStore { domain: Option<&str>, password: &str, ) -> Result<()> { + logger_ctdra::debug( + "db", + &format!( + "storing credential for pair '{pair_id}' ({})", + side.as_str() + ), + ); let key = resolve_master_key()?; let (ciphertext, nonce) = crypto::encrypt(password.as_bytes(), &key)?; let now = now_unix(); @@ -85,6 +92,13 @@ impl CredentialStore { /// Liest und entschlüsselt die Zugangsdaten für `pair_id`/`side`, falls vorhanden. pub async fn get(&self, pair_id: &str, side: Side) -> Result> { + logger_ctdra::debug( + "db", + &format!( + "reading credential for pair '{pair_id}' ({})", + side.as_str() + ), + ); let mut rows = self .conn .query( @@ -117,6 +131,13 @@ impl CredentialStore { /// Löscht Zugangsdaten. `side = None` löscht beide Seiten (z. B. beim Entfernen eines Paars). pub async fn delete(&self, pair_id: &str, side: Option) -> Result<()> { + logger_ctdra::debug( + "db", + &format!( + "deleting credential(s) for pair '{pair_id}' ({})", + side.map(|s| s.as_str()).unwrap_or("both sides") + ), + ); match side { Some(side) => { self.conn diff --git a/src/db/mod.rs b/src/db/mod.rs index db3a816..8c9c4f1 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -21,6 +21,7 @@ pub fn resolve_db_path() -> PathBuf { /// Öffnet (und initialisiert bei Bedarf) die lokale Datenbank am aufgelösten Pfad. pub async fn open() -> Result { let path = resolve_db_path(); + logger_ctdra::debug("db", &format!("opening database at '{}'", path.display())); if let Some(dir) = path.parent() { tokio::fs::create_dir_all(dir) .await diff --git a/src/main.rs b/src/main.rs index f9238f8..e7c55b4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,13 @@ async fn main() -> ExitCode { Err(_) => "info".to_string(), }; logger_ctdra::set_log_level(parse_log_level(&log_level)); + logger_ctdra::info( + "main", + &format!( + "smart-mount {} starting (log level: {log_level})", + env!("CARGO_PKG_VERSION") + ), + ); let cli = cli::Cli::parse(); match cli::dispatch(cli).await { diff --git a/src/mount/lock.rs b/src/mount/lock.rs index fe45cff..0b96b65 100644 --- a/src/mount/lock.rs +++ b/src/mount/lock.rs @@ -23,9 +23,16 @@ use crate::error::{Error, Result}; /// Datei-Deskriptor), bis der Guard gedroppt wird - Schließen des Deskriptors gibt die Sperre /// implizit frei, ein explizites `unlock()` ist dafür nicht nötig. pub struct PairLock { + pair_id: String, _file: File, } +impl Drop for PairLock { + fn drop(&mut self) { + logger_ctdra::debug("lock", &format!("pair '{}': lock released", self.pair_id)); + } +} + /// Verzeichnis für die Sperrdateien: `/run/smart-mount/locks` im Root-/System-Kontext (root ist /// dort ohnehin die einzige Partei, die Paare in diesem Kontext mountet), sonst /// `$XDG_RUNTIME_DIR` (per-Nutzer, von systemd `0700`-geschützt angelegt) mit Fallback auf das @@ -59,8 +66,13 @@ fn acquire_blocking(pair_id: &str) -> Result { // Blockiert, bis die Sperre frei wird - `flock(2)` kennt keinen Async-Mechanismus, daher // läuft dieser gesamte Aufruf über `spawn_blocking` (siehe [`acquire`]) auf einem // Blocking-Thread statt einem Tokio-Worker-Thread. + logger_ctdra::debug("lock", &format!("pair '{pair_id}': waiting for lock")); file.lock().map_err(|e| Error::io(&path, e))?; - Ok(PairLock { _file: file }) + logger_ctdra::debug("lock", &format!("pair '{pair_id}': lock acquired")); + Ok(PairLock { + pair_id: pair_id.to_string(), + _file: file, + }) } /// Sperrt ein Laufwerkspaar prozessübergreifend für die Dauer des zurückgegebenen Guards. diff --git a/src/mount/mod.rs b/src/mount/mod.rs index 96974b2..d8096e1 100644 --- a/src/mount/mod.rs +++ b/src/mount/mod.rs @@ -93,6 +93,17 @@ fn run_tolerating_already_done_with_timeout( tolerate_timeout: bool, timeout_secs: u64, ) -> Result<()> { + logger_ctdra::debug( + "mount", + &format!( + "{context}: running '{} {}' (timeout {timeout_secs}s)", + cmd.get_program().to_string_lossy(), + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>() + .join(" ") + ), + ); let output = run_with_timeout(cmd, timeout_secs).map_err(|e| crate::error::Error::MountFailed { context: context.to_string(), @@ -100,6 +111,7 @@ fn run_tolerating_already_done_with_timeout( })?; if output.status.success() { + logger_ctdra::debug("mount", &format!("{context}: succeeded")); return Ok(()); } @@ -173,16 +185,20 @@ fn cleanup_side_credentials( match kind { MountKind::Smb => { let path = smb::credentials_path(&pair.id, side); - if path.exists() - && let Err(e) = std::fs::remove_file(&path) - { - logger_ctdra::warn( - "mount", - &format!( - "Could not delete credentials file '{}': {e}", - path.display() + if path.exists() { + match std::fs::remove_file(&path) { + Ok(()) => logger_ctdra::debug( + "mount", + &format!("deleted credentials file '{}'", path.display()), ), - ); + Err(e) => logger_ctdra::warn( + "mount", + &format!( + "Could not delete credentials file '{}': {e}", + path.display() + ), + ), + } } } MountKind::WebDav => { diff --git a/src/mount/smb.rs b/src/mount/smb.rs index 848e286..e889aa2 100644 --- a/src/mount/smb.rs +++ b/src/mount/smb.rs @@ -29,7 +29,12 @@ impl MountBackend for SmbBackend { fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> { if let Some(cred) = cred { - write_credentials_file(&credentials_path(&target.pair_id, target.side), cred)?; + let path = credentials_path(&target.pair_id, target.side); + logger_ctdra::debug( + "mount", + &format!("cifs: writing credentials file '{}'", path.display()), + ); + write_credentials_file(&path, cred)?; } Ok(()) } diff --git a/src/mount/target.rs b/src/mount/target.rs index 90d38d2..62cadd9 100644 --- a/src/mount/target.rs +++ b/src/mount/target.rs @@ -301,6 +301,15 @@ pub fn activate_symlink(pair: &DrivePair, side: Side) -> Result<()> { let _ = std::fs::remove_file(&tmp_path); std::os::unix::fs::symlink(&target, &tmp_path).map_err(|e| Error::io(&tmp_path, e))?; std::fs::rename(&tmp_path, link_path).map_err(|e| Error::io(link_path, e))?; + logger_ctdra::info( + "mount", + &format!( + "pair '{}': activated '{}' -> '{}'", + pair.id, + link_path.display(), + target.display() + ), + ); Ok(()) } diff --git a/src/mount/webdav.rs b/src/mount/webdav.rs index 60eedc6..c7920b0 100644 --- a/src/mount/webdav.rs +++ b/src/mount/webdav.rs @@ -46,6 +46,10 @@ impl MountBackend for WebDavBackend { "davfs2 credential has an empty username or password".to_string(), )); } + logger_ctdra::debug( + "mount", + &format!("davfs2: writing secrets entry for '{}'", target.source), + ); write_secrets_entry( &davfs2_secrets_path(), &target.source, diff --git a/src/network/mac2ip.rs b/src/network/mac2ip.rs index 2ca991f..d89d512 100644 --- a/src/network/mac2ip.rs +++ b/src/network/mac2ip.rs @@ -38,6 +38,12 @@ enum Mac2IpOutput { /// `binary` ist der konfigurierte Binary-Name/-Pfad (`GlobalSettings::mac2ip_binary`, /// standardmäßig `"mac2ip"`, per PATH aufgelöst). pub fn resolve(mac: &str, binary: &str) -> Result { + logger_ctdra::info( + "network", + &format!( + "resolving MAC '{mac}' via '{binary}' (may take up to {MAC2IP_TIMEOUT_SECS}s, e.g. for an nmap scan)" + ), + ); let output = Command::new("timeout") .arg(MAC2IP_TIMEOUT_SECS.to_string()) .arg(binary) @@ -49,13 +55,25 @@ pub fn resolve(mac: &str, binary: &str) -> Result { })?; if output.status.code() == Some(124) { + logger_ctdra::warn( + "network", + &format!("'{binary}' did not respond within {MAC2IP_TIMEOUT_SECS}s for MAC '{mac}'"), + ); return Err(Error::Mac2Ip { mac: mac.to_string(), reason: format!("'{binary}' did not respond within {MAC2IP_TIMEOUT_SECS}s (timed out)"), }); } - parse_output(&output.stdout, mac) + let result = parse_output(&output.stdout, mac); + match &result { + Ok(ip) => logger_ctdra::info("network", &format!("MAC '{mac}' resolved to {ip}")), + Err(e) => logger_ctdra::debug( + "network", + &format!("MAC '{mac}' could not be resolved: {e}"), + ), + } + result } /// Prüft, ob das konfigurierte `mac2ip`-Binary über `PATH` auffindbar ist. diff --git a/src/network/mod.rs b/src/network/mod.rs index 4531136..c078235 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -17,6 +17,15 @@ use std::process::{Command, Stdio}; /// (z. B. ein kaputter Reverse-Proxy) sonst bei jedem `watch`-Durchlauf einen vollen, /// letztlich erfolglosen Umschaltversuch (inkl. `MOUNT_TIMEOUT_SECS`-Wartezeit) auslösen würde. pub fn is_reachable(addr: &str) -> bool { + let reachable = is_reachable_inner(addr); + logger_ctdra::debug( + "network", + &format!("reachability check for '{addr}': {reachable}"), + ); + reachable +} + +fn is_reachable_inner(addr: &str) -> bool { if addr.starts_with("http://") || addr.starts_with("https://") { Command::new("curl") .args([ diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index ed59c47..8be20e1 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -50,6 +50,13 @@ pub struct ReconcileOutcome { /// Risiko für einen Effizienzgewinn, den die Reachability-Parallelisierung bereits liefert. pub async fn watch_once(cfg: &AppConfig, creds: &CredentialStore) -> Vec { let enabled: Vec<&DrivePair> = cfg.pairs.iter().filter(|p| p.enabled).collect(); + logger_ctdra::debug( + "reconcile", + &format!( + "watch: checking reachability for {} enabled pair(s)", + enabled.len() + ), + ); let mut checks = Vec::with_capacity(enabled.len()); for pair in &enabled { @@ -123,16 +130,28 @@ async fn reconcile_pair_checked( ) .await { - Ok(action) => ReconcileOutcome { - pair_id, - pair_name, - action, - }, - Err(e) => ReconcileOutcome { - pair_id, - pair_name, - action: Action::Failed(e.to_string()), - }, + Ok(action) => { + logger_ctdra::info( + "reconcile", + &format!("pair '{pair_name}' ({pair_id}): {action:?}"), + ); + ReconcileOutcome { + pair_id, + pair_name, + action, + } + } + Err(e) => { + logger_ctdra::error( + "reconcile", + &format!("pair '{pair_name}' ({pair_id}): reconcile failed: {e}"), + ); + ReconcileOutcome { + pair_id, + pair_name, + action: Action::Failed(e.to_string()), + } + } } } @@ -147,6 +166,13 @@ async fn reconcile_pair_inner( let _guard = lock::acquire(&pair.id).await?; let active = target::active_side(pair); + logger_ctdra::debug( + "reconcile", + &format!( + "pair '{}': local_reachable={local_reachable} cloud_reachable={cloud_reachable} active={:?}", + pair.id, active + ), + ); cleanup_orphaned_mounts(pair, settings, active).await; if local_reachable { @@ -182,7 +208,13 @@ async fn reconcile_pair_inner( fn check_local_reachable(local: &LocalSide, settings: &GlobalSettings) -> (bool, Option) { match address::resolve_ip(&local.address, settings) { Ok(ip) => (network::is_reachable(&ip.to_string()), Some(ip)), - Err(_) => (false, None), + Err(e) => { + logger_ctdra::debug( + "reconcile", + &format!("local address could not be resolved: {e}"), + ); + (false, None) + } } } @@ -251,6 +283,15 @@ async fn switch_to( old_active: Option, cached_local_ip: Option, ) -> Result<()> { + logger_ctdra::info( + "reconcile", + &format!( + "pair '{}': switching to '{}' (previously active: {:?})", + pair.id, + new_side.as_str(), + old_active + ), + ); mount_side(pair, settings, new_side, creds, cached_local_ip).await?; // Falls das Aktivieren des Symlinks fehlschlägt, muss die gerade gemountete `new_side` @@ -280,6 +321,16 @@ async fn mount_side( ) -> Result<()> { let mount_target = target::build_target_with_cached_local_ip(pair, settings, side, cached_local_ip)?; + logger_ctdra::debug( + "reconcile", + &format!( + "pair '{}' ({}): resolved source '{}' -> '{}'", + pair.id, + side.as_str(), + mount_target.source, + mount_target.mount_point.display() + ), + ); std::fs::create_dir_all(&mount_target.mount_point) .map_err(|e| crate::error::Error::io(&mount_target.mount_point, e))?; // Backing-Verzeichnis auf `owner_user` chownen (0700) - relevant vor allem für NFS, wo es @@ -293,6 +344,15 @@ async fn mount_side( backend.check_available()?; let cred = creds.get(&pair.id, side).await?; backend.prepare(&mount_target, cred.as_ref())?; + logger_ctdra::info( + "reconcile", + &format!( + "pair '{}' ({}): mounting via {} ...", + pair.id, + side.as_str(), + backend.name() + ), + ); backend.mount(&mount_target) } @@ -301,6 +361,7 @@ async fn mount_side( /// ein leeres, ausgehängtes Backing-Verzeichnis) - der nächste `mount`/`watch`-Lauf räumt das /// beim erneuten Aktivieren automatisch auf. pub async fn unmount_pair(pair: &DrivePair, settings: &GlobalSettings) -> Result { + logger_ctdra::info("reconcile", &format!("pair '{}': unmounting", pair.id)); let _guard = lock::acquire(&pair.id).await?; // Beide Seiten werden unabhängig voneinander versucht - ein Fehler (auch ein @@ -363,7 +424,18 @@ async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) - owner_user: pair.owner_user.clone(), } }); - mount::backend_for(target::side_kind(pair, side)).unmount(&mount_target) + let backend = mount::backend_for(target::side_kind(pair, side)); + logger_ctdra::debug( + "reconcile", + &format!( + "pair '{}' ({}): unmounting via {} ({})", + pair.id, + side.as_str(), + backend.name(), + mount_target.mount_point.display() + ), + ); + backend.unmount(&mount_target) } #[cfg(test)] diff --git a/src/systemd/mod.rs b/src/systemd/mod.rs index 4a751da..180d3fd 100644 --- a/src/systemd/mod.rs +++ b/src/systemd/mod.rs @@ -67,6 +67,7 @@ pub fn install_cron(watch_interval_secs: u64) -> Result { std::fs::set_permissions(CRON_D_PATH, std::fs::Permissions::from_mode(0o644)) .map_err(|e| Error::io(CRON_D_PATH, e))?; } + logger_ctdra::info("systemd", &format!("cron entry written to '{CRON_D_PATH}'")); Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH))) } @@ -96,6 +97,10 @@ pub fn uninstall_cron() -> Result { return Ok(CronUninstallOutcome::NotPresent); } std::fs::remove_file(path).map_err(|e| Error::io(CRON_D_PATH, e))?; + logger_ctdra::info( + "systemd", + &format!("cron entry removed from '{CRON_D_PATH}'"), + ); Ok(CronUninstallOutcome::Removed) } From eff6dafa77049ad122584ea98aa3f4f82ae04ff5 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 20 Sep 2026 16:55:24 +0200 Subject: [PATCH 2/5] =?UTF-8?q?Fix:=20Klarere=20Feldbezeichnung=20f=C3=BCr?= =?UTF-8?q?=20'share'/Export-Pfad=20in=20'drive=20add'/'edit'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der generische Prompt-/Hilfetext "Local/Cloud share/export path" war unabhängig vom gewählten Mount-Typ identisch, obwohl das Feld inhaltlich je nach Backend etwas anderes ist (SMB-Freigabename, NFS-Export-Pfad, WebDAV-URL-Pfad). share_field_label() wählt jetzt passend zum bereits gewählten local_kind/cloud_kind einen eindeutigen Text mit Beispiel. Co-Authored-By: Claude Sonnet 5 --- src/cli/drive.rs | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/cli/drive.rs b/src/cli/drive.rs index e9e1ef5..e8cea97 100644 --- a/src/cli/drive.rs +++ b/src/cli/drive.rs @@ -56,6 +56,8 @@ pub struct DriveArgs { local_ip: Option, #[arg(long, conflicts_with = "local_ip")] local_mac: Option, + /// Meaning depends on '--local-kind': the SMB share name (e.g. 'media'), the NFS export + /// path (e.g. '/export/media'), or the WebDAV URL path (e.g. '/remote.php/dav/files/user'). #[arg(long)] local_share: Option, #[arg(long)] @@ -72,6 +74,8 @@ pub struct DriveArgs { cloud_kind: Option, #[arg(long)] cloud_host: Option, + /// Meaning depends on '--cloud-kind': the SMB share name (e.g. 'media'), the NFS export + /// path (e.g. '/export/media'), or the WebDAV URL path (e.g. '/remote.php/dav/files/user'). #[arg(long)] cloud_share: Option, #[arg(long)] @@ -90,6 +94,18 @@ pub struct DriveArgs { non_interactive: bool, } +/// Beschriftet das `share`-Feld abhängig vom bereits gewählten Mount-Typ statt des +/// generischen "share/export path" - die drei Backends legen dort inhaltlich verschiedene +/// Dinge ab (SMB-Freigabename, NFS-Export-Pfad, WebDAV-URL-Pfad), siehe +/// `mount::target::format_source`. +fn share_field_label(kind: MountKind, prefix: &str) -> String { + match kind { + MountKind::WebDav => format!("{prefix} WebDAV path (e.g. '/remote.php/dav/files/user')"), + MountKind::Smb => format!("{prefix} share name (e.g. 'media')"), + MountKind::Nfs => format!("{prefix} export path (e.g. '/export/media')"), + } +} + pub async fn run(action: DriveAction) -> anyhow::Result<()> { match action { DriveAction::Add(args) => add(args).await, @@ -166,8 +182,14 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { let local_kind = resolve_kind(args.local_kind, None, "Local mount type", ni)?; let local_address = resolve_local_address(args.local_ip, args.local_mac, None, ni)?; - let local_share = resolve_field(args.local_share, None, "Local share/export path", ni, true)? - .expect("required"); + let local_share = resolve_field( + args.local_share, + None, + &share_field_label(local_kind, "Local"), + ni, + true, + )? + .expect("required"); let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?; let (local_username, local_password) = resolve_credentials( local_kind, @@ -181,8 +203,14 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { let cloud_kind = resolve_kind(args.cloud_kind, None, "Cloud mount type", ni)?; let cloud_host = resolve_field(args.cloud_host, None, "Cloud server address/URL", ni, true)? .expect("required"); - let cloud_share = resolve_field(args.cloud_share, None, "Cloud share/export path", ni, true)? - .expect("required"); + let cloud_share = resolve_field( + args.cloud_share, + None, + &share_field_label(cloud_kind, "Cloud"), + ni, + true, + )? + .expect("required"); let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?; let (cloud_username, cloud_password) = resolve_credentials( cloud_kind, @@ -272,7 +300,7 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { let local_share = resolve_field( args.local_share, Some(&existing.local.share), - "Local share/export path", + &share_field_label(local_kind, "Local"), ni, true, )? @@ -307,7 +335,7 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { let cloud_share = resolve_field( args.cloud_share, Some(&existing.cloud.share), - "Cloud share/export path", + &share_field_label(cloud_kind, "Cloud"), ni, true, )? From abcc11f07861bcbb386e0ba025da749b39b8ee70 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 20 Sep 2026 17:07:11 +0200 Subject: [PATCH 3/5] =?UTF-8?q?Feature:=20Shell-Completion=20wird=20automa?= =?UTF-8?q?tisch=20mitpaketiert=20statt=20=C3=BCber=20eigenen=20Befehl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entfernt den 'smart-mount completions'-Befehl komplett - Shell-Completion (bash/zsh/fish) wird stattdessen beim Bauen des .deb/.rpm/.pkg.tar.zst-Pakets generiert und als normales Paket-Asset ausgeliefert, sodass sie beim Installieren automatisch mit installiert und beim Entfernen automatisch mit entfernt wird, ganz ohne Zutun des Nutzers. Das 'cli'-Modul ist dafür Teil der Library geworden (pub mod cli), damit ein neues, separates Build-Hilfsprogramm (src/bin/generate-completions.rs) die bestehende Cli-Definition als einzige Quelle wiederverwenden kann, statt sie zu duplizieren. Die Gitea-Workflows rufen es nach dem Release-Build auf; Cargo.toml und scripts/package-arch.py bündeln die erzeugten Dateien für alle drei Paketformate an den jeweils distributionsüblichen Pfaden. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/main.yaml | 7 ++++++- .gitea/workflows/testing.yaml | 7 ++++++- Cargo.toml | 12 ++++++++++++ README.md | 8 ++++---- scripts/package-arch.py | 29 ++++++++++++++++++++++++++++ src/bin/generate-completions.rs | 34 +++++++++++++++++++++++++++++++++ src/cli/doctor.rs | 4 ++-- src/cli/drive.rs | 18 ++++++++--------- src/cli/mod.rs | 18 ++--------------- src/cli/mount_cmd.rs | 8 ++++---- src/cli/service.rs | 5 +++-- src/cli/status.rs | 10 +++++----- src/cli/watch.rs | 7 +++---- src/lib.rs | 1 + src/main.rs | 3 +-- 15 files changed, 120 insertions(+), 51 deletions(-) create mode 100644 src/bin/generate-completions.rs diff --git a/.gitea/workflows/main.yaml b/.gitea/workflows/main.yaml index b9fac9e..7769ea2 100644 --- a/.gitea/workflows/main.yaml +++ b/.gitea/workflows/main.yaml @@ -59,7 +59,7 @@ jobs: cargo-${{ runner.os }}- - name: Alte Paketierungs-Ausgaben aus dem Cache entfernen - run: rm -rf target/debian target/generate-rpm target/arch + run: rm -rf target/debian target/generate-rpm target/arch target/completions - name: Install Cross-Compilation Toolchains (apt) run: | @@ -111,6 +111,11 @@ jobs: cargo build --release --target x86_64-unknown-linux-gnu cargo build --release --target aarch64-unknown-linux-gnu + - name: Generate Shell Completions + run: | + cargo build --release --bin generate-completions + ./target/release/generate-completions target/completions + - name: Determine Build Number id: build_num env: diff --git a/.gitea/workflows/testing.yaml b/.gitea/workflows/testing.yaml index 42cf728..0d49f7f 100644 --- a/.gitea/workflows/testing.yaml +++ b/.gitea/workflows/testing.yaml @@ -59,7 +59,7 @@ jobs: cargo-${{ runner.os }}- - name: Alte Paketierungs-Ausgaben aus dem Cache entfernen - run: rm -rf target/debian target/generate-rpm target/arch + run: rm -rf target/debian target/generate-rpm target/arch target/completions - name: Install Cross-Compilation Toolchains (apt) run: | @@ -111,6 +111,11 @@ jobs: cargo build --release --target x86_64-unknown-linux-gnu cargo build --release --target aarch64-unknown-linux-gnu + - name: Generate Shell Completions + run: | + cargo build --release --bin generate-completions + ./target/release/generate-completions target/completions + - name: Determine Build Number id: build_num env: diff --git a/Cargo.toml b/Cargo.toml index d44ca91..cd952df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,13 @@ assets = [ ["packaging/systemd/smart-mount-mount.service", "usr/lib/systemd/system/smart-mount-mount.service", "644"], ["packaging/systemd/smart-mount-watch.service", "usr/lib/systemd/system/smart-mount-watch.service", "644"], ["packaging/systemd/smart-mount-watch.timer", "usr/lib/systemd/system/smart-mount-watch.timer", "644"], + # Von 'cargo build --bin generate-completions' erzeugt (siehe .gitea/workflows/main.yaml) - + # kein 'smart-mount completions'-Subcommand mehr, Shell-Completion wird stattdessen wie jedes + # andere Paket-Asset automatisch mit installiert/entfernt. Debian/Ubuntu laden Zsh-Completions + # aus 'vendor-completions' (anders als Fedora/Arch, siehe [package.metadata.generate-rpm]). + ["target/completions/smart-mount.bash", "usr/share/bash-completion/completions/smart-mount", "644"], + ["target/completions/_smart-mount", "usr/share/zsh/vendor-completions/_smart-mount", "644"], + ["target/completions/smart-mount.fish", "usr/share/fish/vendor_completions.d/smart-mount.fish", "644"], ] [package.metadata.generate-rpm] @@ -78,6 +85,11 @@ assets = [ { source = "packaging/systemd/smart-mount-mount.service", dest = "/usr/lib/systemd/system/smart-mount-mount.service", mode = "644" }, { source = "packaging/systemd/smart-mount-watch.service", dest = "/usr/lib/systemd/system/smart-mount-watch.service", mode = "644" }, { source = "packaging/systemd/smart-mount-watch.timer", dest = "/usr/lib/systemd/system/smart-mount-watch.timer", mode = "644" }, + # Fedora/RHEL (wie Arch, siehe scripts/package-arch.py) laden Zsh-Completions aus + # 'site-functions' statt Debians 'vendor-completions' (siehe [package.metadata.deb]). + { source = "target/completions/smart-mount.bash", dest = "/usr/share/bash-completion/completions/smart-mount", mode = "644" }, + { source = "target/completions/_smart-mount", dest = "/usr/share/zsh/site-functions/_smart-mount", mode = "644" }, + { source = "target/completions/smart-mount.fish", dest = "/usr/share/fish/vendor_completions.d/smart-mount.fish", mode = "644" }, ] requires = { "mac2ip" = "*" } suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" } diff --git a/README.md b/README.md index 32130a7..57e5226 100644 --- a/README.md +++ b/README.md @@ -88,12 +88,12 @@ sudo smart-mount service crontab --remove # einzeln beim Mount-Fehlschlag entdecken würde smart-mount doctor smart-mount doctor --json - -# Shell-Completion-Skript ausgeben (bash/zsh/fish/elvish/powershell) -smart-mount completions bash > /etc/bash_completion.d/smart-mount -smart-mount completions zsh > "${fpath[1]}/_smart-mount" ``` +Shell-Completion (bash/zsh/fish) wird beim Installieren des .deb/.rpm/.pkg.tar.zst-Pakets +automatisch mit installiert (und beim Entfernen des Pakets automatisch mit entfernt) - dafür ist +kein eigenes `smart-mount`-Subcommand nötig. + Die Konfiguration liegt immer unter `/etc/smart-mount/config.toml`, unabhängig davon, ob `smart-mount` selbst mit oder ohne Root-Rechte aufgerufen wird (siehe oben). Die verschlüsselte Zugangsdaten-Datenbank (`smart-mount.db`) liegt im selben Verzeichnis. diff --git a/scripts/package-arch.py b/scripts/package-arch.py index 80a6f69..fcfbb36 100755 --- a/scripts/package-arch.py +++ b/scripts/package-arch.py @@ -61,6 +61,27 @@ def collect_systemd_units(systemd_src_dir="packaging/systemd"): return unit_files, installable +def collect_completions(completions_dir="target/completions"): + """Findet die von 'cargo build --bin generate-completions' erzeugten Shell-Completion- + Skripte (siehe src/bin/generate-completions.rs), falls vorhanden, und ordnet sie generisch - + ohne den Anwendungsnamen zu kennen - allein anhand ihrer clap_complete-Namenskonvention + (Bash: '.bash', Zsh: '_', Fish: '.fish') dem jeweiligen + System-Verzeichnis zu. Gibt eine Liste aus (Quellpfad, Zielverzeichnis, Zieldateiname) zurück.""" + if not os.path.isdir(completions_dir): + return [] + + mapping = [] + for f in sorted(os.listdir(completions_dir)): + src = os.path.join(completions_dir, f) + if f.endswith(".bash"): + mapping.append((src, "usr/share/bash-completion/completions", f[: -len(".bash")])) + elif f.endswith(".fish"): + mapping.append((src, "usr/share/fish/vendor_completions.d", f)) + elif f.startswith("_"): + mapping.append((src, "usr/share/zsh/site-functions", f)) + return mapping + + def build_install_scriptlet(installable, all_units): """Erzeugt den Inhalt einer Arch-'.INSTALL'-Datei (siehe `man PKGBUILD`, Abschnitt 'install'), die den paketierten systemd-Dienst beim Installieren aktiviert/startet und beim @@ -155,6 +176,14 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None): check=True, ) + for src, dest_dir, dest_name in collect_completions(): + target_dir = os.path.join(build_dir, dest_dir) + os.makedirs(target_dir, exist_ok=True) + subprocess.run( + ["install", "-m", "644", src, os.path.join(target_dir, dest_name)], + check=True, + ) + installed_size = subprocess.check_output(["du", "-sb", build_dir]).decode().split()[0] builddate = str(int(time.time())) diff --git a/src/bin/generate-completions.rs b/src/bin/generate-completions.rs new file mode 100644 index 0000000..fc7dc52 --- /dev/null +++ b/src/bin/generate-completions.rs @@ -0,0 +1,34 @@ +//! Erzeugt die Shell-Completion-Skripte (bash/zsh/fish) für `smart-mount` als Dateien in einem +//! Ausgabeverzeichnis - kein `smart-mount`-Subcommand mehr (siehe `smart_mount::cli::Commands`), +//! sondern ein separates Build-Hilfsprogramm, das ausschließlich während der CI-Paketierung +//! aufgerufen wird (siehe `.gitea/workflows/main.yaml`/`testing.yaml`): die erzeugten Dateien +//! werden dort als normale Paket-Assets gebündelt, sodass sie beim Installieren des .deb/.rpm/ +//! .pkg.tar.zst-Pakets automatisch mit installiert und beim Entfernen automatisch mit entfernt +//! werden - ganz ohne Zutun des Nutzers. +//! +//! Nutzt `smart_mount::cli::Cli` als einzige Quelle der CLI-Definition (kein separat gepflegtes +//! Duplikat), da `cli` als `pub mod` Teil der Library ist und so auch von diesem zusätzlichen +//! Binary-Target aus der Cargo-eigenen `src/bin/`-Autodiscovery erreichbar ist. + +use std::path::PathBuf; + +use clap::CommandFactory; +use clap_complete::Shell; +use smart_mount::cli::Cli; + +fn main() { + let Some(out_dir) = std::env::args_os().nth(1).map(PathBuf::from) else { + eprintln!("usage: generate-completions "); + std::process::exit(1); + }; + std::fs::create_dir_all(&out_dir) + .unwrap_or_else(|e| panic!("could not create '{}': {e}", out_dir.display())); + + let mut cmd = Cli::command(); + let bin_name = cmd.get_name().to_string(); + for shell in [Shell::Bash, Shell::Zsh, Shell::Fish] { + let path = clap_complete::generate_to(shell, &mut cmd, &bin_name, &out_dir) + .unwrap_or_else(|e| panic!("could not generate {shell} completions: {e}")); + println!("generated: {}", path.display()); + } +} diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index 952d570..daa5167 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -1,8 +1,8 @@ //! `smart-mount doctor` - prüft die im Laufe der Entwicklung angesammelten Voraussetzungen //! (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) gebündelt an einer Stelle. -use smart_mount::config; -use smart_mount::doctor::{self, CheckStatus}; +use crate::config; +use crate::doctor::{self, CheckStatus}; pub async fn run(json: bool) -> anyhow::Result<()> { let cfg = config::pairs::load()?; diff --git a/src/cli/drive.rs b/src/cli/drive.rs index e8cea97..c81c070 100644 --- a/src/cli/drive.rs +++ b/src/cli/drive.rs @@ -12,10 +12,8 @@ use std::str::FromStr; use clap::{Args, Subcommand}; use dialoguer::{Confirm, Input, Password, Select}; -use smart_mount::config::{ - self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountKind, -}; -use smart_mount::db::credentials::{Credential, CredentialStore, Side}; +use crate::config::{self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountKind}; +use crate::db::credentials::{Credential, CredentialStore, Side}; #[derive(Subcommand)] pub enum DriveAction { @@ -145,7 +143,7 @@ async fn remove(id: &str) -> anyhow::Result<()> { // Vor dem Entfernen aus der Config nachschlagen, damit wir hinterher noch wissen, welche // Mount-Typen/Adressen betroffen sind - nötig, um die passenden Klartext-Zugangsdaten - // (davfs2 secrets, .cred-Datei) aufzuräumen, siehe smart_mount::mount::cleanup_credentials. + // (davfs2 secrets, .cred-Datei) aufzuräumen, siehe crate::mount::cleanup_credentials. let cfg = config::pairs::load()?; let pair = config::pairs::find_pair(&cfg, id)?; @@ -154,7 +152,7 @@ async fn remove(id: &str) -> anyhow::Result<()> { // darf das Entfernen aus der Konfiguration nicht blockieren - sonst käme der Nutzer nicht // mehr an sein eigenes `drive remove` heran, ohne vorher erst als root/mit den richtigen // Rechten manuell auszuhängen. - if let Err(e) = smart_mount::reconcile::unmount_pair(&pair, &cfg.settings).await { + if let Err(e) = crate::reconcile::unmount_pair(&pair, &cfg.settings).await { logger_ctdra::warn( "drive", &format!("could not unmount drive pair '{id}' before removal: {e}"), @@ -163,7 +161,7 @@ async fn remove(id: &str) -> anyhow::Result<()> { config::pairs::remove_pair(id)?; let creds = CredentialStore::open().await?; creds.delete(id, None).await?; - smart_mount::mount::cleanup_credentials(&pair, &cfg.settings); + crate::mount::cleanup_credentials(&pair, &cfg.settings); logger_ctdra::info("drive", &format!("drive pair '{id}' removed")); println!("Drive pair '{id}' removed."); @@ -356,7 +354,7 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { // würde ein aktiver Mount verwaisen. Nur eine explizite '--mount-point'-Angabe ändert ihn. let mount_point = match args.mount_point { Some(p) => { - if smart_mount::mount::target::active_side(&existing).is_some() { + if crate::mount::target::active_side(&existing).is_some() { println!( "Warning: pair '{id}' appears to be actively mounted - the old backing \ directories will be orphaned. Run 'sudo smart-mount unmount --name {id}' \ @@ -515,7 +513,7 @@ fn invoking_user() -> Option { /// Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff /// (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership - der -/// Mount selbst läuft immer als root (siehe [`smart_mount::systemd`]). `default_owner` ist der +/// Mount selbst läuft immer als root (siehe [`crate::systemd`]). `default_owner` ist der /// vorbelegte/vorgeschlagene Wert (bei `add`: der einrichtende Nutzer, siehe `invoking_user`; /// bei `edit`: der bisherige `owner_user`) - immer überschreibbar per Flag, im interaktiven /// Prompt auch durch Ablehnen oder einen anderen Namen. @@ -693,7 +691,7 @@ fn resolve_credentials( #[cfg(test)] mod tests { use super::*; - use smart_mount::config::LocalAddress; + use crate::config::LocalAddress; use std::net::Ipv4Addr; fn sample_pair(mount_point: &str) -> DrivePair { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f8cd2e3..42516c1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -7,12 +7,11 @@ pub mod service; pub mod status; pub mod watch; -use clap::{CommandFactory, Parser, Subcommand}; -use clap_complete::Shell; +use clap::{Parser, Subcommand}; /// Startet den Prozess bei Bedarf transparent über `sudo` neu, falls nicht bereits als root /// aufgerufen. Mounten läuft ausschließlich als root/System-Dienst (siehe -/// [`smart_mount::systemd`]) - es gibt seit dem Wegfall des Nutzerkontexts keine +/// [`crate::systemd`]) - es gibt seit dem Wegfall des Nutzerkontexts keine /// unprivilegierte Mount-Variante mehr, für die ein Rechte-Check hier zu früh käme. /// /// `run_as_root()` ersetzt den aktuellen Prozess per `execve` und kehrt bei Erfolg nie zurück @@ -88,9 +87,6 @@ pub enum Commands { #[arg(long)] json: bool, }, - /// Prints a shell completion script, e.g.: - /// `smart-mount completions bash > /etc/bash_completion.d/smart-mount`. - Completions { shell: Shell }, } /// Kurzname eines Subcommands fürs Logging (siehe [`dispatch`]) - kein `Debug`-Derive auf @@ -104,7 +100,6 @@ fn command_label(cmd: &Commands) -> &'static str { Commands::Watch => "watch", Commands::Service { .. } => "service", Commands::Doctor { .. } => "doctor", - Commands::Completions { .. } => "completions", } } @@ -122,14 +117,5 @@ pub async fn dispatch(cli: Cli) -> anyhow::Result<()> { Commands::Watch => watch::run().await, Commands::Service { action } => service::run(action), Commands::Doctor { json } => doctor::run(json).await, - Commands::Completions { shell } => { - clap_complete::generate( - shell, - &mut Cli::command(), - "smart-mount", - &mut std::io::stdout(), - ); - Ok(()) - } } } diff --git a/src/cli/mount_cmd.rs b/src/cli/mount_cmd.rs index 82e0d77..d1f36ba 100644 --- a/src/cli/mount_cmd.rs +++ b/src/cli/mount_cmd.rs @@ -1,11 +1,11 @@ //! `smart-mount mount` / `smart-mount unmount`. -use smart_mount::config::{self, DrivePair}; -use smart_mount::db::credentials::CredentialStore; -use smart_mount::reconcile; +use crate::config::{self, DrivePair}; +use crate::db::credentials::CredentialStore; +use crate::reconcile; fn select_pairs( - cfg: &smart_mount::config::AppConfig, + cfg: &crate::config::AppConfig, name: Option<&str>, all: bool, ) -> anyhow::Result> { diff --git a/src/cli/service.rs b/src/cli/service.rs index 8c36fe1..68890e4 100644 --- a/src/cli/service.rs +++ b/src/cli/service.rs @@ -1,8 +1,9 @@ //! `smart-mount service crontab [--remove]`. use clap::Subcommand; -use smart_mount::config; -use smart_mount::systemd; + +use crate::config; +use crate::systemd; #[derive(Subcommand)] pub enum ServiceAction { diff --git a/src/cli/status.rs b/src/cli/status.rs index 524bc3b..2a90703 100644 --- a/src/cli/status.rs +++ b/src/cli/status.rs @@ -2,10 +2,10 @@ use serde::Serialize; -use smart_mount::config; -use smart_mount::db::credentials::Side; -use smart_mount::mount::target; -use smart_mount::network; +use crate::config; +use crate::db::credentials::Side; +use crate::mount::target; +use crate::network; #[derive(Serialize)] struct PairStatus { @@ -48,7 +48,7 @@ pub async fn run(name: Option, json: bool) -> anyhow::Result<()> { // wiederverwendet, statt sie für beide Zwecke unabhängig voneinander ein zweites // Mal aufzulösen. let resolved_local_ip = - smart_mount::network::address::resolve_ip(&pair.local.address, &cfg.settings); + crate::network::address::resolve_ip(&pair.local.address, &cfg.settings); let local_source = resolved_local_ip .as_ref() .map(|ip| target::format_local_source(&pair.local, *ip)) diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 8352c87..bf6bbd0 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -1,10 +1,9 @@ //! `smart-mount watch` - ein einzelner Reconcile-Durchlauf, gedacht für systemd-Timer/Cron. -use smart_mount::config; -use smart_mount::db::credentials::CredentialStore; -use smart_mount::reconcile::{self, Action}; - use crate::cli::mount_cmd::print_outcome; +use crate::config; +use crate::db::credentials::CredentialStore; +use crate::reconcile::{self, Action}; pub async fn run() -> anyhow::Result<()> { crate::cli::require_root("watch")?; diff --git a/src/lib.rs b/src/lib.rs index 3757831..b2e1695 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ //! smart-mount: bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und //! schaltet automatisch zwischen LAN und Cloud um. +pub mod cli; pub mod config; pub mod crypto; pub mod db; diff --git a/src/main.rs b/src/main.rs index e7c55b4..323f24a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,7 @@ -mod cli; - use std::process::ExitCode; use clap::Parser; +use smart_mount::cli; #[tokio::main] async fn main() -> ExitCode { From 86d43761d95a2fcae3ae58ddb2841c3e8cb05e2b Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 20 Sep 2026 17:32:18 +0200 Subject: [PATCH 4/5] =?UTF-8?q?Feature:=20Entfernt=20'service'-Befehl,=20C?= =?UTF-8?q?ron-Fallback=20wird=20automatisch=20mitpaketiert,=20doctor=20pr?= =?UTF-8?q?=C3=BCft=20distro-=20und=20statusgenau=20nach?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kein 'smart-mount service crontab'-Subcommand mehr - das neue interne Hilfsprogramm setup-cron (src/bin/setup-cron.rs, installiert nach /usr/lib/smart-mount/setup-cron) richtet den Cron-Fallback beim Paketieren automatisch über die postinst-Skripte ein, sofern kein systemd verfügbar ist; postrm/das Arch-.install-Skriptlet entfernen den Cron-Eintrag beim Deinstallieren ebenso automatisch wieder. systemd::binary_path() ist dafür jetzt fest auf '/usr/bin/smart-mount' gesetzt statt current_exe() zu nutzen, da der Cron-Eintrag nun von einem separaten Prozess geschrieben wird. 'smart-mount doctor' zeigt Installationshinweise jetzt nur noch für die über /etc/os-release erkannte Distribution (Debian/Fedora/Arch) statt immer alle drei Varianten zu nennen, und der Scheduler-Check prüft echten Status statt bloßer Tool-Präsenz: systemctl is-enabled/is-active für die smart-mount-Units sowie tatsächliche Existenz von /etc/cron.d/smart-mount plus laufendem Cron-Daemon-Prozess. Co-Authored-By: Claude Sonnet 5 --- Cargo.toml | 27 ++++- README.md | 40 ++++--- packaging/deb/postinst | 16 ++- packaging/deb/postrm | 19 ++- scripts/package-arch.py | 80 +++++++++---- src/bin/setup-cron.rs | 51 ++++++++ src/cli/mod.rs | 10 -- src/cli/service.rs | 62 ---------- src/doctor.rs | 249 ++++++++++++++++++++++++++++++++++++---- src/systemd/mod.rs | 119 +++++++++++++++---- 10 files changed, 496 insertions(+), 177 deletions(-) create mode 100644 src/bin/setup-cron.rs delete mode 100644 src/cli/service.rs diff --git a/Cargo.toml b/Cargo.toml index cd952df..85a7256 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,10 @@ assets = [ ["target/completions/smart-mount.bash", "usr/share/bash-completion/completions/smart-mount", "644"], ["target/completions/_smart-mount", "usr/share/zsh/vendor-completions/_smart-mount", "644"], ["target/completions/smart-mount.fish", "usr/share/fish/vendor_completions.d/smart-mount.fish", "644"], + # Kein 'smart-mount service'-Subcommand mehr - packaging/deb/postinst ruft dieses interne + # Hilfsprogramm (siehe src/bin/setup-cron.rs) stattdessen automatisch auf und richtet damit + # bei Bedarf (kein systemd, aber /etc/cron.d vorhanden) den Cron-Fallback ein. + ["target/release/setup-cron", "usr/lib/smart-mount/setup-cron", "755"], ] [package.metadata.generate-rpm] @@ -90,22 +94,33 @@ assets = [ { source = "target/completions/smart-mount.bash", dest = "/usr/share/bash-completion/completions/smart-mount", mode = "644" }, { source = "target/completions/_smart-mount", dest = "/usr/share/zsh/site-functions/_smart-mount", mode = "644" }, { source = "target/completions/smart-mount.fish", dest = "/usr/share/fish/vendor_completions.d/smart-mount.fish", mode = "644" }, + # Kein 'smart-mount service'-Subcommand mehr - post_install_script ruft dieses interne + # Hilfsprogramm (siehe src/bin/setup-cron.rs) stattdessen automatisch auf und richtet damit + # bei Bedarf (kein systemd, aber /etc/cron.d vorhanden) den Cron-Fallback ein. + { source = "target/release/setup-cron", dest = "/usr/lib/smart-mount/setup-cron", mode = "755" }, ] requires = { "mac2ip" = "*" } suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" } -# postinst/postrm-Äquivalente: richten den paketierten systemd-Dienst automatisch ein/entfernen -# (Gegenstück zu packaging/deb/postinst+postrm). $1 in %postun: Anzahl verbleibender Versionen -# nach diesem Schritt - 0 nur bei vollständiger Deinstallation, nicht bei einem Upgrade. +# postinst/postrm-Äquivalente: richten den paketierten systemd-Dienst (bzw. ohne systemd den +# Cron-Fallback über setup-cron) automatisch ein/entfernen (Gegenstück zu +# packaging/deb/postinst+postrm). $1 in %postun: Anzahl verbleibender Versionen nach diesem +# Schritt - 0 nur bei vollständiger Deinstallation, nicht bei einem Upgrade. Die Cron-Datei wird +# in %postun direkt per "rm -f" entfernt statt über setup-cron selbst: zu diesem Zeitpunkt sind +# die Paketdateien - und damit auch setup-cron selbst - bereits entfernt. post_install_script = """ if command -v systemctl >/dev/null 2>&1; then systemctl daemon-reload || true systemctl enable --now smart-mount-mount.service smart-mount-watch.timer || true fi +/usr/lib/smart-mount/setup-cron || true """ post_uninstall_script = """ -if [ "$1" = "0" ] && command -v systemctl >/dev/null 2>&1; then - systemctl disable --now smart-mount-mount.service smart-mount-watch.timer smart-mount-watch.service || true - systemctl daemon-reload || true +if [ "$1" = "0" ]; then + if command -v systemctl >/dev/null 2>&1; then + systemctl disable --now smart-mount-mount.service smart-mount-watch.timer smart-mount-watch.service || true + systemctl daemon-reload || true + fi + rm -f /etc/cron.d/smart-mount fi """ diff --git a/README.md b/README.md index 57e5226..48304fa 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,9 @@ werden (z. B. über ein separates Sync-Tool). - `cifs-utils` für SMB/CIFS-Laufwerke - `nfs-common` (Debian/Ubuntu) bzw. `nfs-utils` (Fedora/Arch) für NFS-Laufwerke - `systemd`: smart-mount richtet sich beim Installieren des Pakets automatisch als - System-Dienst ein (siehe "Automatischer Start beim Systemstart" unten). Ohne systemd bleibt - `smart-mount service crontab` als manueller Fallback. + System-Dienst ein (siehe "Automatischer Start beim Systemstart" unten). Ohne systemd, aber mit + `/etc/cron.d`, wird beim Installieren automatisch ein Cron-Fallback eingerichtet - kein + manueller Schritt nötig. --- @@ -79,11 +80,6 @@ smart-mount status --json # Ein Reconcile-Durchlauf (lokal/Cloud-Umschaltung) - für systemd-Timer/Cron gedacht sudo smart-mount watch -# Cron-Fallback für Systeme ohne (genutzten) systemd - siehe "Automatischer Start beim -# Systemstart" unten -sudo smart-mount service crontab -sudo smart-mount service crontab --remove - # Voraussetzungen prüfen (Binaries, Scheduler) - deckt gebündelt ab, was man sonst erst # einzeln beim Mount-Fehlschlag entdecken würde smart-mount doctor @@ -168,21 +164,25 @@ sudo systemctl edit smart-mount-watch.timer ### Cron-Fallback (Systeme ohne systemd) -```bash -sudo smart-mount service crontab # einrichten -sudo smart-mount service crontab --remove # wieder entfernen -``` - -Erfordert Root (wie `mount`/`watch` - Mounten läuft immer als root, siehe oben) und schreibt -`/etc/cron.d/smart-mount` - das Intervall folgt `settings.watch_interval_secs`. Ist -`/etc/cron.d` nicht vorhanden, werden stattdessen die beiden äquivalenten Zeilen zum manuellen -Eintragen ausgegeben: +Ist beim Installieren/Upgraden des Pakets kein `systemctl` gefunden, aber `/etc/cron.d` +vorhanden, schreibt das Paket **automatisch** `/etc/cron.d/smart-mount` (kein manueller Schritt, +kein eigenes `smart-mount`-Subcommand) - das Intervall folgt `settings.watch_interval_secs` +(Standard 120s): ```cron -@reboot smart-mount mount --all -*/2 * * * * smart-mount watch +@reboot root /usr/bin/smart-mount mount --all +*/2 * * * * root /usr/bin/smart-mount watch ``` +Zuständig dafür ist `setup-cron` (`src/bin/setup-cron.rs`, installiert nach +`/usr/lib/smart-mount/setup-cron`) - ein internes Hilfsprogramm, kein Teil der öffentlichen +`smart-mount`-CLI, das die postinst-Skripte des Pakets (bzw. das `.INSTALL`-Skriptlet bei Arch) +automatisch aufrufen; beim Deinstallieren wird `/etc/cron.d/smart-mount` ebenso automatisch +wieder entfernt. Ist weder systemd noch `/etc/cron.d` vorhanden, meldet `setup-cron` das nur +auf der Konsole (Paketinstallation schlägt dadurch nicht fehl) - die periodische Ausführung +muss dann manuell eingerichtet werden, z. B. über eine eigene Crontab-Zeile analog zu obigem +Beispiel. + --- ## Architekturentscheidungen @@ -268,7 +268,9 @@ Eintragen ausgegeben: │ └── report-security-issue.py # Security-Scan-Ergebnisse als Gitea-Issue melden ├── src/ │ ├── main.rs # Dünner Einstiegspunkt (CLI-Parsing, Dispatch) -│ ├── lib.rs # Bibliotheks-Wurzel +│ ├── lib.rs # Bibliotheks-Wurzel (inkl. `pub mod cli`) +│ ├── bin/ # Interne Hilfsprogramme (kein Teil der `smart-mount`-CLI): +│ │ # generate-completions (Build-Zeit), setup-cron (Paket-Installation) │ ├── cli/ # `clap`-Subcommands │ ├── config/ # Konfigurationsschema + CRUD (config-ctdra) │ ├── crypto/ # Verschlüsselung + Master-Key-Auflösung diff --git a/packaging/deb/postinst b/packaging/deb/postinst index a9cd8ea..4eda3a2 100755 --- a/packaging/deb/postinst +++ b/packaging/deb/postinst @@ -1,13 +1,17 @@ #!/bin/sh # Richtet smart-mount beim Installieren/Upgraden des Pakets automatisch als -# System-systemd-Dienst ein (Gegenstueck: postrm). Laeuft nur beim eigentlichen -# "configure"-Schritt (siehe Debian Policy Manual, Abschnitt 6.5), nicht bei -# "abort-upgrade"/"abort-remove" etc. +# System-systemd-Dienst ein (Gegenstueck: postrm); ist kein systemd verfuegbar, richtet +# stattdessen setup-cron (siehe src/bin/setup-cron.rs) automatisch den Cron-Fallback ein, +# sofern /etc/cron.d existiert. Laeuft nur beim eigentlichen "configure"-Schritt (siehe +# Debian Policy Manual, Abschnitt 6.5), nicht bei "abort-upgrade"/"abort-remove" etc. set -e -if [ "$1" = "configure" ] && command -v systemctl >/dev/null 2>&1; then - systemctl daemon-reload || true - systemctl enable --now smart-mount-mount.service smart-mount-watch.timer || true +if [ "$1" = "configure" ]; then + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl enable --now smart-mount-mount.service smart-mount-watch.timer || true + fi + /usr/lib/smart-mount/setup-cron || true fi exit 0 diff --git a/packaging/deb/postrm b/packaging/deb/postrm index b6e577b..08cee2f 100755 --- a/packaging/deb/postrm +++ b/packaging/deb/postrm @@ -1,13 +1,20 @@ #!/bin/sh -# Entfernt den beim Installieren eingerichteten systemd-Dienst wieder (Gegenstueck: +# Entfernt den beim Installieren eingerichteten systemd-Dienst sowie einen eventuell von +# setup-cron (siehe src/bin/setup-cron.rs) angelegten Cron-Fallback wieder (Gegenstueck: # postinst). Nur bei tatsaechlicher Entfernung ("remove"/"purge"), nicht bei einem -# Upgrade (dort ersetzt dpkg die Unit-Dateien einfach durch die neue Version, ohne den -# laufenden Dienst zwischenzeitlich zu deaktivieren). +# Upgrade (dort ersetzt dpkg die Unit-/Paket-Dateien einfach durch die neue Version, ohne +# den laufenden Dienst/Cron-Eintrag zwischenzeitlich zu deaktivieren). Die Cron-Datei wird +# hier direkt per "rm -f" entfernt statt ueber setup-cron selbst aufgerufen zu werden: zum +# Zeitpunkt, zu dem "postrm remove" laeuft, hat dpkg die Paketdateien - und damit auch +# setup-cron selbst - bereits entfernt (siehe Debian Policy Manual, Abschnitt 6.5). set -e -if { [ "$1" = "remove" ] || [ "$1" = "purge" ]; } && command -v systemctl >/dev/null 2>&1; then - systemctl disable --now smart-mount-mount.service smart-mount-watch.timer smart-mount-watch.service || true - systemctl daemon-reload || true +if { [ "$1" = "remove" ] || [ "$1" = "purge" ]; }; then + if command -v systemctl >/dev/null 2>&1; then + systemctl disable --now smart-mount-mount.service smart-mount-watch.timer smart-mount-watch.service || true + systemctl daemon-reload || true + fi + rm -f /etc/cron.d/smart-mount fi exit 0 diff --git a/scripts/package-arch.py b/scripts/package-arch.py index fcfbb36..2376a6e 100755 --- a/scripts/package-arch.py +++ b/scripts/package-arch.py @@ -82,30 +82,55 @@ def collect_completions(completions_dir="target/completions"): return mapping -def build_install_scriptlet(installable, all_units): +def find_helper_binary(name, helper, target_triple=None): + """Sucht eine optionale interne Hilfs-Binary (eigenes Cargo-Binary-Target unter + 'src/bin/.rs', z. B. fuer einen Cron-Fallback) am selben kompilierten Ort wie die + Haupt-Binary - generisch ueber den fest bekannten Hilfsprogramm-Namen, ohne den + Anwendungsnamen selbst zu kennen. Gibt None zurueck, falls nicht gefunden (z. B. weil das + abgeleitete Projekt kein solches Hilfsprogramm hat).""" + candidates = [] + if target_triple: + candidates.append(f"target/{target_triple}/release/{helper}") + candidates.append(f"target/release/{helper}") + for p in candidates: + if os.path.exists(p): + return p + return None + + +def build_install_scriptlet(name, installable, all_units, has_cron_helper): """Erzeugt den Inhalt einer Arch-'.INSTALL'-Datei (siehe `man PKGBUILD`, Abschnitt - 'install'), die den paketierten systemd-Dienst beim Installieren aktiviert/startet und beim - Entfernen wieder deaktiviert/stoppt - rein generisch anhand der tatsächlich gefundenen - Unit-Dateien, ohne Anwendungsnamen hart zu codieren.""" + 'install'): aktiviert/startet paketierte systemd-Units beim Installieren/Upgraden und + deaktiviert sie beim Entfernen, und ruft - sofern vorhanden - generisch das interne + Cron-Fallback-Hilfsprogramm ('usr/lib//setup-cron', siehe src/bin/setup-cron.rs) beim + Installieren/Upgraden auf bzw. entfernt dessen Cron-Datei beim Entfernen wieder. Rein anhand + der tatsächlich gefundenen Unit-Dateien/Hilfsprogramme zusammengesetzt, ohne den + Anwendungsnamen selbst hart zu codieren (der als `name`-Parameter hereinkommt).""" installable_str = " ".join(installable) all_units_str = " ".join(all_units) - return f"""post_install() {{ - systemctl daemon-reload >/dev/null 2>&1 || true - systemctl enable --now {installable_str} >/dev/null 2>&1 || true -}} -post_upgrade() {{ - systemctl daemon-reload >/dev/null 2>&1 || true -}} + systemd_enable = ( + " systemctl daemon-reload >/dev/null 2>&1 || true\n" + f" systemctl enable --now {installable_str} >/dev/null 2>&1 || true\n" + if installable + else "" + ) + systemd_reload = " systemctl daemon-reload >/dev/null 2>&1 || true\n" if all_units else "" + systemd_disable = ( + f" systemctl disable --now {all_units_str} >/dev/null 2>&1 || true\n" if all_units else "" + ) + # Ein zusaetzlicher Cron-Eintrag ist redundant, wenn systemd verfuegbar ist - das + # Hilfsprogramm selbst prueft das (siehe src/bin/setup-cron.rs), hier wird es einfach + # unconditional aufgerufen. + cron_install = f" /usr/lib/{name}/setup-cron >/dev/null 2>&1 || true\n" if has_cron_helper else "" + cron_remove = f" rm -f /etc/cron.d/{name}\n" if has_cron_helper else "" -pre_remove() {{ - systemctl disable --now {all_units_str} >/dev/null 2>&1 || true -}} - -post_remove() {{ - systemctl daemon-reload >/dev/null 2>&1 || true -}} -""" + return ( + "post_install() {\n" + systemd_enable + cron_install + "}\n\n" + "post_upgrade() {\n" + systemd_reload + cron_install + "}\n\n" + "pre_remove() {\n" + systemd_disable + cron_remove + "}\n\n" + "post_remove() {\n" + systemd_reload + "}\n" + ) def build_package(target_triple=None, target_arch=None, pkgrel=None): @@ -184,6 +209,16 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None): check=True, ) + setup_cron_path = find_helper_binary(name, "setup-cron", target_triple) + has_cron_helper = setup_cron_path is not None + if has_cron_helper: + helper_dir = os.path.join(build_dir, f"usr/lib/{name}") + os.makedirs(helper_dir, exist_ok=True) + subprocess.run( + ["install", "-m", "755", setup_cron_path, os.path.join(helper_dir, "setup-cron")], + check=True, + ) + installed_size = subprocess.check_output(["du", "-sb", build_dir]).decode().split()[0] builddate = str(int(time.time())) @@ -203,7 +238,8 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None): pkginfo_lines.append(f"depend = {dep}") for optdep in optdepends: pkginfo_lines.append(f"optdepend = {optdep}") - if all_units: + needs_install_scriptlet = bool(all_units) or has_cron_helper + if needs_install_scriptlet: pkginfo_lines.append(f"install = {name}.install") pkginfo_lines.append("makepkgopt = strip\n") @@ -211,10 +247,10 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None): f.write("\n".join(pkginfo_lines)) tar_members = [".PKGINFO", "usr"] - if all_units: + if needs_install_scriptlet: install_script_name = f"{name}.install" with open(os.path.join(build_dir, install_script_name), "w") as f: - f.write(build_install_scriptlet(installable_units, all_units)) + f.write(build_install_scriptlet(name, installable_units, all_units, has_cron_helper)) tar_members.append(install_script_name) os.makedirs("target/arch", exist_ok=True) diff --git a/src/bin/setup-cron.rs b/src/bin/setup-cron.rs new file mode 100644 index 0000000..2ebfb4e --- /dev/null +++ b/src/bin/setup-cron.rs @@ -0,0 +1,51 @@ +//! Richtet den Cron-Fallback für den periodischen `watch`-Lauf automatisch ein, sofern kein +//! systemd verfügbar ist - kein `smart-mount service`-Subcommand mehr (siehe +//! `smart_mount::cli::Commands`), sondern ein separates, nicht auf `PATH` liegendes +//! Hilfsprogramm (installiert nach `/usr/lib/smart-mount/setup-cron`), das ausschließlich vom +//! postinst-Skript der .deb/.rpm/.pkg.tar.zst-Pakete mit dem Argument `install` aufgerufen wird +//! (siehe `packaging/deb/postinst`, `[package.metadata.generate-rpm].post_install_script` in +//! `Cargo.toml`, sowie `scripts/package-arch.py`). Das Entfernen eines zuvor installierten +//! Cron-Eintrags beim Deinstallieren übernehmen die jeweiligen postrm-Skripte/Scriptlets direkt +//! (ein einfaches `rm -f`) statt dieses Programm mit `remove` aufzurufen: zum Zeitpunkt, zu dem +//! `postrm remove` bei Debian bzw. `%postun` bei RPM laufen, sind die Paketdateien - und damit +//! auch dieses Hilfsprogramm selbst - bereits von der Platte entfernt. +//! +//! Nutzt `smart_mount::systemd`/`smart_mount::config` als einzige Quelle der +//! Cron-Installationslogik (kein separat gepflegtes Duplikat in Shell), da beide als `pub mod` +//! Teil der Library sind und so auch von diesem zusätzlichen Binary-Target aus der +//! Cargo-eigenen `src/bin/`-Autodiscovery erreichbar sind. + +use smart_mount::config; +use smart_mount::systemd; + +fn main() { + // Ein zusätzlicher Cron-Eintrag wäre redundant/unerwünscht, wenn systemd verfügbar ist - die + // paketierten systemd-Units (siehe packaging/systemd/) übernehmen dann bereits alles. + if systemd::is_available() { + return; + } + + config::init(); + let watch_interval_secs = config::pairs::load() + .map(|cfg| cfg.settings.watch_interval_secs) + .unwrap_or(120); + + match systemd::install_cron(watch_interval_secs) { + Ok(systemd::CronInstallOutcome::SystemFile(path)) => { + println!( + "smart-mount: cron fallback installed at '{}'", + path.display() + ); + } + Ok(systemd::CronInstallOutcome::Unavailable) => { + eprintln!( + "smart-mount: neither systemd nor '/etc/cron.d' found - the periodic 'watch' \ + call must be set up manually, e.g.:\n{}", + systemd::crontab_equivalent(watch_interval_secs) + ); + } + Err(e) => { + eprintln!("smart-mount: could not install the cron fallback: {e}"); + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 42516c1..5186370 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -3,7 +3,6 @@ pub mod doctor; pub mod drive; pub mod mount_cmd; -pub mod service; pub mod status; pub mod watch; @@ -74,13 +73,6 @@ pub enum Commands { }, /// A single reconcile pass (local/cloud switching) - meant for systemd timers/cron. Watch, - /// Sets up (or removes) the cron fallback for systems without (or not using) systemd - - /// the systemd service itself is installed/removed automatically by the .deb/.rpm/ - /// .pkg.tar.zst package, not via this CLI. - Service { - #[command(subcommand)] - action: service::ServiceAction, - }, /// Checks prerequisites (binaries, scheduler). Doctor { /// Output as JSON instead of text - for scripts. @@ -98,7 +90,6 @@ fn command_label(cmd: &Commands) -> &'static str { Commands::Unmount { .. } => "unmount", Commands::Status { .. } => "status", Commands::Watch => "watch", - Commands::Service { .. } => "service", Commands::Doctor { .. } => "doctor", } } @@ -115,7 +106,6 @@ pub async fn dispatch(cli: Cli) -> anyhow::Result<()> { Commands::Unmount { name, all } => mount_cmd::run_unmount(name, all).await, Commands::Status { name, json } => status::run(name, json).await, Commands::Watch => watch::run().await, - Commands::Service { action } => service::run(action), Commands::Doctor { json } => doctor::run(json).await, } } diff --git a/src/cli/service.rs b/src/cli/service.rs deleted file mode 100644 index 68890e4..0000000 --- a/src/cli/service.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! `smart-mount service crontab [--remove]`. - -use clap::Subcommand; - -use crate::config; -use crate::systemd; - -#[derive(Subcommand)] -pub enum ServiceAction { - /// Sets up (or, with '--remove', tears down) periodic execution via - /// '/etc/cron.d/smart-mount' - a manual fallback for systems that don't use the packaged - /// systemd service (see 'packaging/systemd/' in the source tree). Requires root, same as - /// 'mount'/'watch' - mounting always runs as root. - Crontab { - /// Removes a previously installed cron entry instead of installing one. - #[arg(long)] - remove: bool, - }, -} - -pub fn run(action: ServiceAction) -> anyhow::Result<()> { - crate::cli::require_root("service crontab")?; - match action { - ServiceAction::Crontab { remove: false } => install(), - ServiceAction::Crontab { remove: true } => uninstall(), - } -} - -fn install() -> anyhow::Result<()> { - let cfg = config::pairs::load()?; - let interval = cfg.settings.watch_interval_secs; - - match systemd::install_cron(interval)? { - systemd::CronInstallOutcome::SystemFile(path) => { - logger_ctdra::info( - "service", - &format!("cron entry written: {}", path.display()), - ); - println!("Cron entry written: {}", path.display()); - } - systemd::CronInstallOutcome::Unavailable => { - println!( - "No cron mechanism found ('/etc/cron.d' is missing) - here are the lines for manual entry:" - ); - print!("{}", systemd::crontab_equivalent(interval)); - } - } - Ok(()) -} - -fn uninstall() -> anyhow::Result<()> { - match systemd::uninstall_cron()? { - systemd::CronUninstallOutcome::Removed => { - logger_ctdra::info("service", "cron entry removed"); - println!("Cron entry removed."); - } - systemd::CronUninstallOutcome::NotPresent => { - println!("Nothing to remove - no cron entry was installed.") - } - } - Ok(()) -} diff --git a/src/doctor.rs b/src/doctor.rs index 4f0689e..072e7be 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -1,12 +1,86 @@ //! Diagnose-Checks für `smart-mount doctor` - prüft die im Laufe der Entwicklung //! angesammelten Voraussetzungen (Binaries, Scheduler) gebündelt an einer Stelle, statt sie //! einzeln erst beim Mount-Fehlschlag zu entdecken. +//! +//! Installationshinweise (fehlendes Paket, Scheduler-Fallback) sind bewusst je nach +//! erkannter Distribution unterschiedlich (siehe [`PackageFamily`]): auf einem Debian-System +//! soll `doctor` nur den `apt`-Hinweis zeigen, nicht zusätzlich Fedora/Arch-Varianten, die dort +//! ohnehin nicht anwendbar sind. use std::collections::HashSet; use crate::config::{AppConfig, LocalAddress, MountKind}; use crate::mount; +/// Grob erkannte Paketmanager-Familie des laufenden Systems, allein zur Auswahl der passenden +/// Installationshinweise in `doctor` - keine vollständige Distributions-Erkennung. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PackageFamily { + /// Debian, Ubuntu und Ableitungen (`apt`). + Debian, + /// Fedora, RHEL/CentOS und Ableitungen (`dnf`). + Fedora, + /// Arch Linux und Ableitungen (Manjaro, EndeavourOS, ...) (`pacman`). + Arch, + /// Nicht erkannt - Installationshinweise bleiben generisch (nur Paketname, kein Befehl). + Unknown, +} + +impl PackageFamily { + fn install_command(self, package: &str) -> Option { + match self { + PackageFamily::Debian => Some(format!("apt install {package}")), + PackageFamily::Fedora => Some(format!("dnf install {package}")), + PackageFamily::Arch => Some(format!("pacman -S {package}")), + PackageFamily::Unknown => None, + } + } +} + +/// Liest `/etc/os-release` (auf praktisch jedem modernen Linux-System vorhanden, siehe +/// `man os-release`) und leitet daraus die [`PackageFamily`] ab. +fn detect_package_family() -> PackageFamily { + std::fs::read_to_string("/etc/os-release") + .map(|contents| parse_package_family(&contents)) + .unwrap_or(PackageFamily::Unknown) +} + +/// Reine, testbare Parse-Funktion: sammelt `ID=`/`ID_LIKE=` aus dem `os-release`-Inhalt (deckt +/// z. B. Ubuntu `ID=ubuntu ID_LIKE=debian` oder Manjaro `ID=manjaro ID_LIKE=arch` mit ab, nicht +/// nur die jeweilige "reine" Distribution) und ordnet sie grob einer [`PackageFamily`] zu. +fn parse_package_family(os_release: &str) -> PackageFamily { + let mut fields = String::new(); + for line in os_release.lines() { + if let Some(value) = line + .strip_prefix("ID=") + .or_else(|| line.strip_prefix("ID_LIKE=")) + { + fields.push(' '); + fields.push_str(value.trim_matches('"')); + } + } + let fields = fields.to_lowercase(); + if fields.contains("debian") { + PackageFamily::Debian + } else if fields.contains("fedora") || fields.contains("rhel") { + PackageFamily::Fedora + } else if fields.contains("arch") { + PackageFamily::Arch + } else { + PackageFamily::Unknown + } +} + +/// Formatiert einen Installationshinweis für `package`, inkl. des passenden Befehls für die +/// erkannte [`PackageFamily`] (kein Befehl bei [`PackageFamily::Unknown`] - dann nur der +/// Paketname, ohne eine der drei Varianten zu raten). +fn install_hint(package: &str, family: PackageFamily) -> String { + match family.install_command(package) { + Some(cmd) => format!("install package '{package}' ({cmd})"), + None => format!("install package '{package}'"), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CheckStatus { Ok, @@ -45,16 +119,17 @@ fn fail(label: impl Into, detail: impl Into) -> CheckResult { /// Führt alle Checks gegen die aktuelle Konfiguration aus. pub fn run_checks(cfg: &AppConfig) -> Vec { + let family = detect_package_family(); let mut results = Vec::new(); results.push(check_timeout_binary()); - results.push(check_scheduler()); + results.push(check_scheduler(family)); results.push(check_mount_base_dir(&cfg.settings.mount_base_dir)); let used_kinds = used_mount_kinds(cfg); for kind in [MountKind::WebDav, MountKind::Smb, MountKind::Nfs] { if used_kinds.contains(&kind) { - results.push(check_backend(kind)); + results.push(check_backend(kind, family)); } } @@ -106,35 +181,99 @@ fn check_binary(name: &str, binary: &str, install_hint: &str) -> CheckResult { } } -fn check_backend(kind: MountKind) -> CheckResult { +fn check_backend(kind: MountKind, family: PackageFamily) -> CheckResult { let backend = mount::backend_for(kind); - match backend.check_available() { - Ok(()) => ok(format!("Backend: {}", backend.name()), "available"), - Err(e) => fail(format!("Backend: {}", backend.name()), e.to_string()), + if backend.check_available().is_ok() { + return ok(format!("Backend: {}", backend.name()), "available"); } + + let (missing_binary, package) = match kind { + MountKind::WebDav => ("mount.davfs", "davfs2"), + MountKind::Smb => ("mount.cifs", "cifs-utils"), + MountKind::Nfs => ( + "mount.nfs/mount.nfs4", + match family { + PackageFamily::Debian => "nfs-common", + _ => "nfs-utils", + }, + ), + }; + fail( + format!("Backend: {}", backend.name()), + format!( + "'{missing_binary}' not found - {}", + install_hint(package, family) + ), + ) } -fn check_scheduler() -> CheckResult { - let systemd = crate::systemd::is_available(); - let cron_d = std::path::Path::new("/etc/cron.d").is_dir(); - let crontab = mount::binary_available("crontab"); +/// Prüft, ob die periodische Ausführung tatsächlich eingerichtet ist UND läuft - nicht nur, ob +/// `systemctl`/ein Cron-Mechanismus grundsätzlich auf dem System vorhanden wäre (das allein +/// sagt nichts darüber aus, ob smart-mounts eigene Units/Cron-Eintrag auch tatsächlich +/// installiert sind, siehe [`crate::systemd::is_unit_installed`]/[`crate::systemd::is_cron_installed`]). +fn check_scheduler(family: PackageFamily) -> CheckResult { + let timer_installed = crate::systemd::is_unit_installed("smart-mount-watch.timer"); + let mount_installed = crate::systemd::is_unit_installed("smart-mount-mount.service"); - if systemd { - ok( - "Scheduler", - "systemd found - the packaged .deb/.rpm/.pkg.tar.zst installs/enables the smart-mount systemd service automatically", - ) - } else if cron_d || crontab { - warn( - "Scheduler", - "no systemd, but cron found - run 'smart-mount service crontab' to set up the periodic 'watch' call", - ) - } else { - fail( - "Scheduler", - "neither systemd nor cron found - periodic 'watch' must be set up manually", - ) + if timer_installed || mount_installed { + let timer_enabled = crate::systemd::is_unit_enabled("smart-mount-watch.timer"); + let timer_active = crate::systemd::is_unit_active("smart-mount-watch.timer"); + let mount_enabled = crate::systemd::is_unit_enabled("smart-mount-mount.service"); + + return if timer_enabled && timer_active && mount_enabled { + ok( + "Scheduler", + "systemd: 'smart-mount-watch.timer' is enabled and running, 'smart-mount-mount.service' is enabled", + ) + } else { + warn( + "Scheduler", + format!( + "systemd units installed but not fully set up (smart-mount-watch.timer: enabled={timer_enabled} running={timer_active}, smart-mount-mount.service: enabled={mount_enabled}) - try 'sudo systemctl enable --now smart-mount-mount.service smart-mount-watch.timer'" + ), + ) + }; } + + if crate::systemd::is_available() { + return fail( + "Scheduler", + "systemd found, but smart-mount's systemd units are not installed - was the package installed correctly?", + ); + } + + if crate::systemd::is_cron_installed() { + return if crate::systemd::is_cron_daemon_running() { + ok( + "Scheduler", + "cron: '/etc/cron.d/smart-mount' is installed, cron daemon is running", + ) + } else { + warn( + "Scheduler", + "cron: '/etc/cron.d/smart-mount' is installed, but no cron daemon process was found - is cron running?", + ) + }; + } + + if std::path::Path::new("/etc/cron.d").is_dir() { + return warn( + "Scheduler", + "no systemd, cron is available but smart-mount's entry ('/etc/cron.d/smart-mount') is missing - was the package installed correctly?", + ); + } + + let cron_package = match family { + PackageFamily::Debian => "cron", + _ => "cronie", + }; + fail( + "Scheduler", + format!( + "neither systemd nor cron found - periodic 'watch' must be set up manually ({})", + install_hint(cron_package, family) + ), + ) } fn check_mount_base_dir(dir: &std::path::Path) -> CheckResult { @@ -165,6 +304,66 @@ mod tests { use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide}; use std::net::Ipv4Addr; + #[test] + fn parse_package_family_detects_debian_and_derivatives() { + assert_eq!( + parse_package_family("ID=debian\nVERSION_ID=\"13\"\n"), + PackageFamily::Debian + ); + assert_eq!( + parse_package_family("ID=ubuntu\nID_LIKE=debian\n"), + PackageFamily::Debian + ); + } + + #[test] + fn parse_package_family_detects_fedora_and_rhel_derivatives() { + assert_eq!(parse_package_family("ID=fedora\n"), PackageFamily::Fedora); + assert_eq!( + parse_package_family("ID=centos\nID_LIKE=\"rhel fedora\"\n"), + PackageFamily::Fedora + ); + } + + #[test] + fn parse_package_family_detects_arch_and_derivatives() { + assert_eq!(parse_package_family("ID=arch\n"), PackageFamily::Arch); + assert_eq!( + parse_package_family("ID=manjaro\nID_LIKE=arch\n"), + PackageFamily::Arch + ); + } + + #[test] + fn parse_package_family_falls_back_to_unknown() { + assert_eq!(parse_package_family(""), PackageFamily::Unknown); + assert_eq!(parse_package_family("ID=gentoo\n"), PackageFamily::Unknown); + } + + #[test] + fn install_hint_includes_package_manager_command_for_known_families() { + assert_eq!( + install_hint("davfs2", PackageFamily::Debian), + "install package 'davfs2' (apt install davfs2)" + ); + assert_eq!( + install_hint("davfs2", PackageFamily::Fedora), + "install package 'davfs2' (dnf install davfs2)" + ); + assert_eq!( + install_hint("davfs2", PackageFamily::Arch), + "install package 'davfs2' (pacman -S davfs2)" + ); + } + + #[test] + fn install_hint_omits_command_for_unknown_family() { + assert_eq!( + install_hint("davfs2", PackageFamily::Unknown), + "install package 'davfs2'" + ); + } + fn sample_pair(owner_user: Option<&str>, mac: bool) -> DrivePair { DrivePair { id: "pair-1".into(), diff --git a/src/systemd/mod.rs b/src/systemd/mod.rs index 180d3fd..d425777 100644 --- a/src/systemd/mod.rs +++ b/src/systemd/mod.rs @@ -1,13 +1,13 @@ -//! Cron-Fallback (`smart-mount service crontab`) für Systeme ohne (oder ohne genutzten) -//! systemd. +//! Cron-Fallback für Systeme ohne (oder ohne genutzten) systemd. //! -//! Die eigentliche systemd-Einrichtung passiert NICHT mehr zur Laufzeit über dieses Modul, -//! sondern über die paketierten, statischen Unit-Dateien (siehe `packaging/systemd/` im -//! Quellbaum) sowie die postinst/postrm-Skripte der .deb/.rpm/.pkg.tar.zst-Pakete - smart-mount -//! richtet sich beim Installieren des Pakets automatisch als System-systemd-Dienst ein und -//! entfernt sich beim Deinstallieren wieder. Dieses Modul bleibt für Systeme ohne systemd (oder -//! zum bewussten Umgehen von systemd) als manueller Cron-Weg bestehen - immer systemweit -//! (`/etc/cron.d/smart-mount`), da Mounten ohnehin immer Root-Rechte braucht (siehe +//! Die eigentliche systemd-Einrichtung passiert NICHT zur Laufzeit über dieses Modul, sondern +//! über die paketierten, statischen Unit-Dateien (siehe `packaging/systemd/` im Quellbaum) +//! sowie die postinst/postrm-Skripte der .deb/.rpm/.pkg.tar.zst-Pakete. Dieses Modul stellt die +//! Logik für den Cron-Fallback bereit - aufgerufen wird sie NICHT interaktiv über ein +//! `smart-mount`-Subcommand, sondern automatisch vom separaten `setup-cron`-Hilfsprogramm +//! (siehe `src/bin/setup-cron.rs`), das ebenfalls über die postinst-Skripte der Pakete beim +//! Installieren läuft, sofern kein systemd verfügbar ist (siehe [`is_available`]). Immer +//! systemweit (`/etc/cron.d/smart-mount`), da Mounten ohnehin immer Root-Rechte braucht (siehe //! [`crate::cli::require_root`]); eine persönliche Nutzer-Crontab liefe ins Leere. use std::path::{Path, PathBuf}; @@ -18,19 +18,96 @@ const CRON_D_PATH: &str = "/etc/cron.d/smart-mount"; const CRON_BEGIN_MARKER: &str = "# BEGIN smart-mount managed block"; const CRON_END_MARKER: &str = "# END smart-mount managed block"; -fn binary_path() -> String { - std::env::current_exe() - .ok() - .and_then(|p| p.to_str().map(str::to_string)) - .unwrap_or_else(|| "/usr/bin/smart-mount".to_string()) +/// Fester Installationspfad der Haupt-Binary (siehe `[package.metadata.deb/generate-rpm].assets` +/// bzw. `scripts/package-arch.py`, die immer nach `/usr/bin/` installieren) - smart-mount +/// läuft ausschließlich als paketierter System-Dienst, es gibt keinen anderen Installationsort. +/// Bewusst NICHT über `std::env::current_exe()`: der Cron-Eintrag wird vom separaten +/// `setup-cron`-Hilfsprogramm geschrieben (siehe [`crate::systemd`]-Moduldoku), dessen eigener +/// Pfad hier falsch wäre. +fn binary_path() -> &'static str { + "/usr/bin/smart-mount" } -/// Ob `systemctl` auf diesem System vorhanden ist - rein informativ für `doctor`; die -/// eigentliche systemd-Einrichtung läuft über die Paketierung (siehe Moduldoku). +/// Ob `systemctl` auf diesem System vorhanden ist - entscheidet für `doctor` UND für +/// `setup-cron` (siehe Moduldoku), ob der Cron-Fallback überhaupt installiert werden soll: die +/// eigentliche systemd-Einrichtung läuft über die Paketierung, ein zusätzlicher Cron-Eintrag +/// wäre dort redundant. pub fn is_available() -> bool { crate::mount::binary_available("systemctl") } +/// `systemctl is-enabled`/`is-active ` liefert bei Erfolg wie bei Misserfolg exakt eine +/// Ausgabezeile auf stdout (z. B. "enabled"/"disabled"/"static"/"not-found" bzw. +/// "active"/"inactive"/"failed") - der Exit-Code allein reicht nicht (z. B. ist `is-active` +/// auch für eine deaktivierte, aber existierende Unit `1`). `None` bei fehlendem `systemctl` +/// oder einem sonstigen Ausführungsfehler. +fn systemctl_query(subcommand: &str, unit: &str) -> Option { + std::process::Command::new("systemctl") + .args([subcommand, unit]) + .output() + .ok() + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) +} + +/// Ob `systemctl` die Unit überhaupt kennt (eine geladene Unit-Datei existiert), unabhängig +/// davon, ob sie aktiviert/aktiv ist - für `doctor`, um "installiert, aber nicht +/// aktiviert/gestartet" von "gar nicht installiert" unterscheiden zu können. Eine komplett +/// unbekannte Unit liefert bei `is-enabled` je nach systemd-Version "not-found" oder eine leere +/// Ausgabe (Fehler auf stderr) - beides ungleich einem der hier aufgeführten, eine tatsächlich +/// geladene Unit-Datei voraussetzenden Zustände. +pub fn is_unit_installed(unit: &str) -> bool { + matches!( + systemctl_query("is-enabled", unit).as_deref(), + Some( + "enabled" + | "disabled" + | "static" + | "alias" + | "linked" + | "generated" + | "transient" + | "masked" + ) + ) +} + +/// Ob die Unit dauerhaft aktiviert ist (startet automatisch, siehe `man systemctl`, +/// Abschnitt "is-enabled"). +pub fn is_unit_enabled(unit: &str) -> bool { + systemctl_query("is-enabled", unit).as_deref() == Some("enabled") +} + +/// Ob die Unit aktuell läuft (bei einem Timer: wartet auf die nächste Auslösung). Bei einem +/// `oneshot`-Service wie `smart-mount-mount.service` (läuft einmal beim Boot und beendet sich +/// danach) ist "inactive" direkt nach einem erfolgreichen Lauf der korrekte, erwartete Zustand - +/// dafür ist [`is_unit_installed`]/[`is_unit_enabled`] aussagekräftiger. +pub fn is_unit_active(unit: &str) -> bool { + systemctl_query("is-active", unit).as_deref() == Some("active") +} + +/// Ob ein von [`install_cron`] verwalteter Cron-Eintrag aktuell existiert - für `doctor`, um +/// zwischen "kein Cron-Mechanismus vorhanden" und "vorhanden, aber smart-mounts Eintrag fehlt" +/// (z. B. weil das Paket nicht sauber installiert wurde) unterscheiden zu können. +pub fn is_cron_installed() -> bool { + Path::new(CRON_D_PATH).exists() +} + +/// Ob aktuell ein Cron-Daemon-Prozess läuft - rein prozessbasiert über `pgrep` geprüft (statt +/// z. B. über `systemctl is-active cron.service`), damit es unabhängig davon funktioniert, ob +/// systemd überhaupt vorhanden ist: genau im Cron-Fallback-Fall (kein/kein genutztes systemd, +/// siehe Moduldoku) ist das der einzige portable Weg. Prüft beide gängigen Prozessnamen +/// (Debian/Ubuntu: `cron`, Fedora/Arch: `crond`), ohne selbst eine Distributions-Erkennung zu +/// brauchen. +pub fn is_cron_daemon_running() -> bool { + ["cron", "crond"].iter().any(|name| { + std::process::Command::new("pgrep") + .args(["-x", name]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + }) +} + /// Erzeugt die Crontab-Äquivalente zu den paketierten systemd-Units, für Systeme ohne /// (genutzten) systemd. `watch_interval_secs` ist derselbe Wert wie /// `settings.watch_interval_secs`. @@ -49,11 +126,11 @@ pub enum CronInstallOutcome { Unavailable, } -/// Richtet die periodische Ausführung direkt über `/etc/cron.d/smart-mount` ein - manueller -/// Fallback für Systeme ohne (genutzten) systemd, sofern `/etc/cron.d` existiert - sonst -/// [`CronInstallOutcome::Unavailable`] statt eines Fehlers, der Aufrufer zeigt dann -/// [`crontab_equivalent`] zur manuellen Einrichtung. Erfordert Root (siehe Moduldoku) - der -/// Aufrufer (`cli::service`) prüft das bereits vorab. +/// Richtet die periodische Ausführung direkt über `/etc/cron.d/smart-mount` ein - Fallback für +/// Systeme ohne (genutzten) systemd, sofern `/etc/cron.d` existiert - sonst +/// [`CronInstallOutcome::Unavailable`] statt eines Fehlers, der Aufrufer (`setup-cron`, siehe +/// Moduldoku) zeigt dann [`crontab_equivalent`] zur manuellen Einrichtung. Erfordert Root +/// (siehe Moduldoku) - beim Aufruf aus dem postinst-Skript der Pakete ohnehin gegeben. pub fn install_cron(watch_interval_secs: u64) -> Result { if !Path::new("/etc/cron.d").is_dir() { return Ok(CronInstallOutcome::Unavailable); From f4220e34aeed7ebca1924c176b0e15f15286ccd6 Mon Sep 17 00:00:00 2001 From: Gitea-Bot Date: Sun, 20 Sep 2026 15:37:05 +0000 Subject: [PATCH 5/5] =?UTF-8?q?Chore:=20Erh=C3=B6ht=20Patch-Version=20auf?= =?UTF-8?q?=202.0.2=20f=C3=BCr=20Promotion=20nach=20testing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 024e7fb..2beb439 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2052,7 +2052,7 @@ checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" [[package]] name = "smart-mount" -version = "2.0.1" +version = "2.0.2" dependencies = [ "aes-gcm 0.11.1", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 85a7256..ba144d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "smart-mount" -version = "2.0.1" +version = "2.0.2" edition = "2024" authors = ['DragonSlayer_14'] readme = "README.md"