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