Author SHA1 Message Date
DragonSlayer_14 bd301132a4 Merge pull request 'Merge dev in testing: Fix: Paketinstallation blockiert nicht mehr synchron auf smart-mount-mount.service' (#7) from dev into testing
Unit-Tests / Unit-Tests (pull_request) Skipped
Auto-PR (Testing → Main) / Erstelle automatisch PR von testing nach main (push) Successful in 11s
Testing Build, Publish & Preview Release / Erkenne relevante Code-Änderungen (push) Successful in 13s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 24s
Security Scans / Trivy & OSV-Scanner (push) Successful in 44s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 44s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Successful in 16m15s
TruffleHog Secret Scan / TruffleHog (push) Successful in 10s
Reviewed-on: #7
2026-09-20 20:39:50 +00:00
Gitea-Bot fc68daddac Chore: Erhöht Patch-Version auf 2.0.4 für Promotion nach testing
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 29s
Security Scans / Trivy & OSV-Scanner (push) Successful in 57s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 56s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 25s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 1m40s
Unit-Tests / Unit-Tests (pull_request) Successful in 3m23s
2026-09-20 20:33:00 +00:00
DragonSlayer_14andClaude Sonnet 5 3bf2ce0718 Fix: Paketinstallation blockiert nicht mehr synchron auf smart-mount-mount.service
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 19s
Security Scans / Trivy & OSV-Scanner (push) Successful in 29s
Auto Patch-Version-Bump (Dev → Testing PR) / Erkenne relevante Änderungen im PR (pull_request) Successful in 13s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 47s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 25s
Auto Patch-Version-Bump (Dev → Testing PR) / Patch-Version erhöhen & auf Dev pushen (pull_request) Successful in 14s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 42s
Unit-Tests / Unit-Tests (pull_request) Successful in 4m18s
'systemctl enable --now smart-mount-mount.service' im postinst/RPM-
Scriptlet wartete synchron auf den kompletten 'mount --all'-Lauf -
bei nicht erreichbaren Laufwerken (Netzwerk-Timeouts pro Paar) blieb
'apt'/'dpkg'/'pacman' dadurch minutenlang ohne sichtbare Rückmeldung
hängen (Logs gehen ins Journal, nicht ins Paketmanager-Terminal).

Jetzt: 'systemctl enable' + 'systemctl start --no-block' für den
Mount-Service (stößt den ersten Mount-Versuch nur an, wartet nicht
darauf), 'enable --now' bleibt für den Watch-Timer (Timer "armen" ist
immer sofort fertig). scripts/package-arch.py unterscheidet das generisch
anhand der Dateiendung (.timer vs. .service), ohne den Anwendungsnamen
hart zu codieren. Zusätzlich TimeoutStartSec=600 im Unit-File als
Obergrenze gegen echte Hänger.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 22:31:53 +02:00
DragonSlayer_14 72c6e45cfb Merge pull request 'Merge dev in testing: Fix: mount/unmount melden Fehlschläge jetzt korrekt, Logs im Journal sichtbar und persistent' (#6) from dev into testing
Unit-Tests / Unit-Tests (pull_request) Skipped
Auto-PR (Testing → Main) / Erstelle automatisch PR von testing nach main (push) Successful in 12s
Testing Build, Publish & Preview Release / Erkenne relevante Code-Änderungen (push) Successful in 12s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 26s
Security Scans / Trivy & OSV-Scanner (push) Successful in 43s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 43s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Successful in 15m53s
TruffleHog Secret Scan / TruffleHog (push) Successful in 10s
Reviewed-on: #6
2026-09-20 17:32:49 +00:00
Gitea-Bot c4a415e3e4 Chore: Erhöht Patch-Version auf 2.0.3 für Promotion nach testing
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 26s
Security Scans / Trivy & OSV-Scanner (push) Successful in 54s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 54s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 28s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 1m29s
Unit-Tests / Unit-Tests (pull_request) Successful in 3m10s
2026-09-20 17:17:13 +00:00
DragonSlayer_14andClaude Sonnet 5 18d84c94f6 Fix: mount/unmount melden Fehlschläge jetzt korrekt, Logs im Journal sichtbar und persistent
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 15s
Security Scans / Trivy & OSV-Scanner (push) Successful in 31s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 24s
Auto Patch-Version-Bump (Dev → Testing PR) / Erkenne relevante Änderungen im PR (pull_request) Successful in 15s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 59s
Auto Patch-Version-Bump (Dev → Testing PR) / Patch-Version erhöhen & auf Dev pushen (pull_request) Successful in 13s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 44s
Unit-Tests / Unit-Tests (pull_request) Successful in 4m12s
'mount --all'/'unmount --all' gaben bisher immer Ok(()) zurück, selbst
wenn einzelne Laufwerkspaare nicht (aus)gehängt werden konnten (nur als
Text ausgegeben, nie propagiert) - anders als 'watch'. Dadurch zeigte
'systemctl status smart-mount-mount' nie "failed", egal was beim
Booten schiefging. run_mount/run_unmount propagieren Fehlschläge jetzt
wie 'watch' als Prozess-Fehler.

Zusätzlich: logger_ctdra::set_log_dir() zeigt jetzt auf
/var/log/smart-mount statt auf einen zufälligen Temp-Ordner, beide
systemd-Units haben ein festes SyslogIdentifier=smart-mount mit
explizitem StandardOutput/StandardError=journal (journalctl -t
smart-mount zeigt damit alles gebündelt), und
smart-mount-mount.service bleibt dank RemainAfterExit=yes nach einem
erfolgreichen Lauf als "active (exited)" sichtbar statt sofort wieder
auf "inactive" zu springen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 19:16:08 +02:00
DragonSlayer_14 105545634d Merge pull request 'Merge dev in testing: Update doctor, Remove service, Add Logs, Rename Prompts' (#5) from dev into testing
Unit-Tests / Unit-Tests (pull_request) Skipped
Auto-PR (Testing → Main) / Erstelle automatisch PR von testing nach main (push) Successful in 12s
Testing Build, Publish & Preview Release / Erkenne relevante Code-Änderungen (push) Successful in 12s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 25s
Security Scans / Trivy & OSV-Scanner (push) Successful in 41s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 42s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Successful in 16m9s
TruffleHog Secret Scan / TruffleHog (push) Successful in 11s
Reviewed-on: #5
2026-09-20 15:43:07 +00:00
Gitea-Bot f4220e34ae Chore: Erhöht Patch-Version auf 2.0.2 für Promotion nach testing
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 28s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 26s
Security Scans / Trivy & OSV-Scanner (push) Successful in 57s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 55s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 1m39s
Unit-Tests / Unit-Tests (pull_request) Successful in 3m18s
2026-09-20 15:37:05 +00:00
DragonSlayer_14andClaude Sonnet 5 86d43761d9 Feature: Entfernt 'service'-Befehl, Cron-Fallback wird automatisch mitpaketiert, doctor prüft distro- und statusgenau nach
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 17s
Security Scans / Trivy & OSV-Scanner (push) Successful in 27s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 36s
Auto Patch-Version-Bump (Dev → Testing PR) / Erkenne relevante Änderungen im PR (pull_request) Successful in 11s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 18s
Auto Patch-Version-Bump (Dev → Testing PR) / Patch-Version erhöhen & auf Dev pushen (pull_request) Successful in 11s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 35s
Unit-Tests / Unit-Tests (pull_request) Successful in 4m4s
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 <noreply@anthropic.com>
2026-09-20 17:32:18 +02:00
DragonSlayer_14andClaude Sonnet 5 abcc11f078 Feature: Shell-Completion wird automatisch mitpaketiert statt über eigenen Befehl
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 <noreply@anthropic.com>
2026-09-20 17:07:11 +02:00
DragonSlayer_14andClaude Sonnet 5 eff6dafa77 Fix: Klarere Feldbezeichnung für 'share'/Export-Pfad in 'drive add'/'edit'
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 <noreply@anthropic.com>
2026-09-20 16:55:24 +02:00
DragonSlayer_14 cc000a2bda Merge remote-tracking branch 'origin/dev' into dev
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 15s
Security Scans / Trivy & OSV-Scanner (push) Successful in 26s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 33s
2026-09-20 16:52:36 +02:00
DragonSlayer_14andClaude Sonnet 5 0ed470281b Feature: Ergänzt info/debug-Logging im gesamten Programm
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 <noreply@anthropic.com>
2026-09-20 16:51:54 +02:00
DragonSlayer_14 705ecd8142 Merge pull request 'Merge dev in testing: Automatischer Aufruf von sudo' (#4) from dev into testing
Unit-Tests / Unit-Tests (pull_request) Skipped
Auto-PR (Testing → Main) / Erstelle automatisch PR von testing nach main (push) Successful in 11s
Testing Build, Publish & Preview Release / Erkenne relevante Code-Änderungen (push) Successful in 14s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 25s
Security Scans / Trivy & OSV-Scanner (push) Successful in 41s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 42s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Successful in 12m0s
TruffleHog Secret Scan / TruffleHog (push) Successful in 10s
Reviewed-on: #4
2026-09-20 14:24:48 +00:00
Gitea-Bot 9c34fc93cb Chore: Erhöht Patch-Version auf 2.0.1 für Promotion nach testing
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 29s
Security Scans / Trivy & OSV-Scanner (push) Successful in 55s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 55s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 26s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 1m39s
Unit-Tests / Unit-Tests (pull_request) Successful in 3m20s
2026-09-20 14:19:22 +00:00
DragonSlayer_14andClaude Sonnet 5 cd9a016cf8 Feature: require_root startet sich bei fehlenden Root-Rechten automatisch per sudo neu
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 15s
Security Scans / Trivy & OSV-Scanner (push) Successful in 24s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 33s
Auto Patch-Version-Bump (Dev → Testing PR) / Erkenne relevante Änderungen im PR (pull_request) Successful in 10s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 17s
Auto Patch-Version-Bump (Dev → Testing PR) / Patch-Version erhöhen & auf Dev pushen (pull_request) Successful in 12s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 35s
Unit-Tests / Unit-Tests (pull_request) Successful in 4m6s
Statt nur mit einer Fehlermeldung abzubrechen, ersetzt sich der Prozess
für root-pflichtige Commands (mount/unmount/watch/service crontab/
drive add/edit/remove) jetzt transparent über sudo-ctdra::run_as_root()
via execve mit sudo neu; der Fehlerpfad greift nur noch, wenn sudo
selbst nicht gefunden werden kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:15:42 +02:00
DragonSlayer_14andClaude Sonnet 5 d88dcac7a0 Fix: Entfernt nmap als eigene Abhängigkeit, mac2ip verlangt es bereits selbst
nmap wird von smart-mount nie direkt aufgerufen, sondern nur intern von
mac2ip für den MAC->IP-Scan verwendet. Da das mac2ip-Paket nmap bereits
selbst als Abhängigkeit deklariert (siehe dessen Depends), war die
zusätzliche Nennung in den deb-/rpm-/arch-Paketmetadaten sowie der
nmap-Check in `doctor` redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 16:10:02 +02:00
37 changed files with 1027 additions and 268 deletions
+6 -1
View File
@@ -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:
+6 -1
View File
@@ -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:
Generated
+1 -1
View File
@@ -2052,7 +2052,7 @@ checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891"
[[package]]
name = "smart-mount"
version = "2.0.0"
version = "2.0.4"
dependencies = [
"aes-gcm 0.11.1",
"anyhow",
+48 -14
View File
@@ -1,6 +1,6 @@
[package]
name = "smart-mount"
version = "2.0.0"
version = "2.0.4"
edition = "2024"
authors = ['DragonSlayer_14']
readme = "README.md"
@@ -46,10 +46,11 @@ maintainer = "DragonSlayer_14"
copyright = "2026 DragonSlayer_14"
section = "net"
priority = "optional"
# mac2ip und nmap sind harte Laufzeitabhängigkeiten (MAC->IP-Auflösung); davfs2/cifs-utils/
# nfs-common werden bewusst NICHT hart verlangt, da smart-mount pro konfiguriertem Laufwerk
# nur das jeweils benötigte Mount-Backend zur Laufzeit prüft/lädt (siehe MountBackend::check_available).
depends = "$auto, mac2ip, nmap"
# mac2ip ist eine harte Laufzeitabhängigkeit (MAC->IP-Auflösung;
# davfs2/cifs-utils/nfs-common werden bewusst NICHT hart verlangt,
# da smart-mount pro konfiguriertem Laufwerk nur das jeweils benötigte Mount-Backend
# zur Laufzeit prüft/lädt (siehe MountBackend::check_available).
depends = "$auto, mac2ip"
recommends = "davfs2, cifs-utils, nfs-common"
extended-description = """\
smart-mount bindet Paare aus einem lokalen (LAN, WebDAV/SMB/NFS) und einem Cloud-Laufwerk
@@ -67,6 +68,17 @@ 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"],
# 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]
@@ -77,29 +89,51 @@ 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" },
# 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" = "*", "nmap" = "*" }
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
# smart-mount-mount.service mountet echte Netzlaufwerke (kann bei nicht erreichbaren
# Servern pro Paar mehrere zehn Sekunden dauern) - "enable --now" wuerde synchron darauf
# warten und damit die Paketinstallation blockieren, daher aktivieren + nur als
# asynchroner Job starten ("--no-block", kehrt sofort zurueck).
systemctl enable smart-mount-mount.service || true
systemctl start --no-block smart-mount-mount.service || true
systemctl enable --now 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
"""
[package.metadata.arch]
pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben
arch = "x86_64"
depends = ["gcc-libs", "glibc", "mac2ip", "nmap"]
depends = ["gcc-libs", "glibc", "mac2ip"]
optdepends = [
"davfs2: WebDAV-Laufwerke einbinden",
"cifs-utils: SMB/CIFS-Laufwerke einbinden",
+46 -28
View File
@@ -14,8 +14,9 @@ werden (z. B. über ein separates Sync-Tool).
## Voraussetzungen
- **[`mac2ip`](https://gitea.creative-dragonslayer.de/Linuxapps/Mac2Ip)** und **`nmap`**: hart
benötigt, sofern lokale Laufwerke per MAC-Adresse adressiert werden (Auflösung MAC → IP).
- **[`mac2ip`](https://gitea.creative-dragonslayer.de/Linuxapps/Mac2Ip)**: hart benötigt, sofern
lokale Laufwerke per MAC-Adresse adressiert werden (Auflösung MAC → IP). `mac2ip` verlangt
`nmap` bereits selbst als eigene Paketabhängigkeit.
- **`timeout`** (GNU coreutils): auf praktisch jedem Linux-System bereits vorhanden (Basis-
Systempaket). Wird verwendet, um `mount`/`umount`-Aufrufe zeitlich zu begrenzen - siehe
"Verhalten bei nicht mehr erreichbarem Server" unten.
@@ -25,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.
---
@@ -78,21 +80,16 @@ 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
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.
@@ -148,13 +145,19 @@ System-systemd-Dienst ein - kein manueller Schritt nötig. Die dafür paketierte
`smart-mount watch` aufruft (Standardintervall: 120s).
Die postinst/postrm-Skripte des Pakets (bzw. das `.INSTALL`-Skriptlet bei Arch) aktivieren und
starten beide Units beim Installieren/Upgraden (`systemctl enable --now ...`) und deaktivieren
sie wieder beim Deinstallieren (`systemctl disable --now ...`) - siehe `packaging/deb/postinst`
+ `packaging/deb/postrm` bzw. `[package.metadata.generate-rpm]`/`scripts/package-arch.py` in
deaktivieren beide Units beim Installieren/Deinstallieren - siehe `packaging/deb/postinst` +
`packaging/deb/postrm` bzw. `[package.metadata.generate-rpm]`/`scripts/package-arch.py` in
`Cargo.toml`. Welche Laufwerkspaare dabei gemountet werden (und für welchen Nutzer, siehe
`owner_user` unten) steuert ausschließlich `/etc/smart-mount/config.toml` - nicht die
Unit-Dateien selbst.
`smart-mount-mount.service` mountet dabei echte Netzlaufwerke, was bei einem gerade nicht
erreichbaren Server pro Paar mehrere zehn Sekunden dauern kann (begrenzt auf max. 10 Minuten
insgesamt, siehe `TimeoutStartSec` in der Unit) - das Paket-Setup wird dadurch **nicht**
blockiert: `systemctl start --no-block` stößt den ersten Mount-Versuch beim Installieren nur an,
statt synchron auf ihn zu warten. `smart-mount-watch.timer` wird dagegen sofort synchron
gestartet (das Timer-"Armen" selbst ist immer augenblicklich fertig).
**Anderes Watch-Intervall als der Standard (120s):** `settings.watch_interval_secs` in
`config.toml` steuert nur den Cron-Fallback (s. u.) - der paketierte Timer hat ein fest
eingebautes Intervall. Zum Anpassen:
@@ -165,23 +168,36 @@ sudo systemctl edit smart-mount-watch.timer
# OnUnitActiveSec=60s
```
**Logs:** beide Units schreiben mit festem `SyslogIdentifier=smart-mount` ins systemd-Journal -
`journalctl -u smart-mount-mount` / `-u smart-mount-watch` (oder gebündelt `journalctl -t
smart-mount`) zeigt sie an. Zusätzlich schreibt smart-mount dieselben Meldungen dauerhaft nach
`/var/log/smart-mount/log-<Datum>.log` (siehe `settings.log_level` für die Ausführlichkeit,
Standard `info`) - unabhängig von der Journal-Rotation. `smart-mount-mount.service` bleibt nach
einem erfolgreichen Lauf als "active (exited)" sichtbar (`RemainAfterExit=yes`), und ein
Fehlschlag beim Mounten mindestens eines konfigurierten Laufwerkspaars lässt den Dienst jetzt
auch tatsächlich als "failed" erscheinen, statt es stillschweigend zu ignorieren.
### 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
@@ -267,7 +283,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
+18 -6
View File
@@ -1,13 +1,25 @@
#!/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
# smart-mount-mount.service mountet echte Netzlaufwerke (kann pro konfiguriertem Paar
# mehrere zehn Sekunden dauern, wenn ein Server gerade nicht erreichbar ist) - "enable
# --now" wuerde synchron darauf warten und damit "apt"/"dpkg" fuer die gesamte Dauer
# blockieren. Daher: aktivieren (fuer den naechsten Boot) und der eigentliche Mount-Lauf
# nur als asynchroner Job ("--no-block", kehrt sofort zurueck) - das Paket-Setup selbst
# wartet also nie auf Netzwerk-Timeouts.
systemctl enable smart-mount-mount.service || true
systemctl start --no-block smart-mount-mount.service || true
systemctl enable --now smart-mount-watch.timer || true
fi
/usr/lib/smart-mount/setup-cron || true
fi
exit 0
+13 -6
View File
@@ -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
@@ -5,7 +5,26 @@ Wants=network-online.target
[Service]
Type=oneshot
# Bleibt nach einem erfolgreichen Lauf als "active (exited)" sichtbar (statt sofort wieder
# "inactive") - macht in 'systemctl status' auf einen Blick erkennbar, dass der letzte Boot-Lauf
# tatsächlich durchlief, statt nur "inactive" zu zeigen (das genauso gut "nie gelaufen" heißen
# könnte).
RemainAfterExit=yes
ExecStart=/usr/bin/smart-mount mount --all
# Harte Obergrenze statt des systemd-Standards (üblicherweise 90s): pro konfiguriertem Paar
# werden Erreichbarkeit UND Mount-Versuch sequenziell geprüft (bis zu ~20s Erreichbarkeits-
# check + 30s Mount-Timeout, siehe crate::mount::MOUNT_TIMEOUT_SECS), bei mehreren Paaren
# summiert sich das - 90s würde bei zwei oder mehr gerade nicht erreichbaren Servern schon
# reichen, um mitten im Mount-Versuch abgebrochen zu werden. 10 Minuten sind großzügig genug für
# eine typische Anzahl Paare, ohne bei einem echten Hänger (z. B. einer verwaisten Dateisperre)
# unbegrenzt zu warten.
TimeoutStartSec=600
# Explizit (statt sich auf den systemd-Standard zu verlassen): stdout/stderr gehen ins Journal,
# unter einem festen, von der jeweiligen Unit unabhängigen Tag - 'journalctl -t smart-mount'
# zeigt damit die Ausgaben aller smart-mount-Units gebündelt.
StandardOutput=journal
StandardError=journal
SyslogIdentifier=smart-mount
[Install]
WantedBy=multi-user.target
@@ -4,3 +4,6 @@ Description=smart-mount: check reachability and switch local/cloud if needed
[Service]
Type=oneshot
ExecStart=/usr/bin/smart-mount watch
StandardOutput=journal
StandardError=journal
SyslogIdentifier=smart-mount
+97 -22
View File
@@ -61,30 +61,86 @@ def collect_systemd_units(systemd_src_dir="packaging/systemd"):
return unit_files, installable
def build_install_scriptlet(installable, all_units):
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: '<name>.bash', Zsh: '_<name>', Fish: '<name>.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 find_helper_binary(name, helper, target_triple=None):
"""Sucht eine optionale interne Hilfs-Binary (eigenes Cargo-Binary-Target unter
'src/bin/<helper>.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."""
installable_str = " ".join(installable)
'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/<name>/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).
'.service'-Units werden bewusst NICHT über 'enable --now' gestartet: Bei einem oneshot-
Service (typischer Fall für ein Boot-Setup-Skript) würde das den ExecStart-Befehl synchron
ausführen und damit 'pacman' für die gesamte Laufzeit des Skripts blockieren (siehe
packaging/deb/postinst für den konkreten Fall, der das erst sichtbar gemacht hat: ein
Mount-Versuch gegen ein gerade nicht erreichbares Netzlaufwerk). '.timer'-Units sind davon
nicht betroffen - sie nur zu "armen" ist immer sofort fertig - und starten daher weiterhin
synchron mit 'enable --now'."""
timers = [u for u in installable if u.endswith(".timer")]
services = [u for u in installable if not u.endswith(".timer")]
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" if installable else ""
if timers:
systemd_enable += f" systemctl enable --now {' '.join(timers)} >/dev/null 2>&1 || true\n"
for service in services:
systemd_enable += f" systemctl enable {service} >/dev/null 2>&1 || true\n"
systemd_enable += f" systemctl start --no-block {service} >/dev/null 2>&1 || true\n"
pre_remove() {{
systemctl disable --now {all_units_str} >/dev/null 2>&1 || true
}}
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 ""
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):
@@ -155,6 +211,24 @@ 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,
)
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()))
@@ -174,7 +248,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")
@@ -182,10 +257,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)
+34
View File
@@ -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 <output-dir>");
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());
}
}
+51
View File
@@ -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}");
}
}
}
+2 -2
View File
@@ -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()?;
+45 -16
View File
@@ -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 {
@@ -56,6 +54,8 @@ pub struct DriveArgs {
local_ip: Option<Ipv4Addr>,
#[arg(long, conflicts_with = "local_ip")]
local_mac: Option<String>,
/// 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<String>,
#[arg(long)]
@@ -72,6 +72,8 @@ pub struct DriveArgs {
cloud_kind: Option<MountKind>,
#[arg(long)]
cloud_host: Option<String>,
/// 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<String>,
#[arg(long)]
@@ -90,6 +92,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,
@@ -129,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)?;
@@ -138,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}"),
@@ -147,8 +161,9 @@ 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.");
Ok(())
}
@@ -165,8 +180,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,
@@ -180,8 +201,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,
@@ -241,6 +268,7 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> {
.await?;
}
logger_ctdra::info("drive", &format!("drive pair '{id}' created"));
println!("Drive pair '{id}' created.");
Ok(())
}
@@ -270,7 +298,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,
)?
@@ -305,7 +333,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,
)?
@@ -326,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}' \
@@ -373,6 +401,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(())
}
@@ -484,7 +513,7 @@ fn invoking_user() -> Option<String> {
/// 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.
@@ -662,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 {
+33 -28
View File
@@ -3,21 +3,29 @@
pub mod doctor;
pub mod drive;
pub mod mount_cmd;
pub mod service;
pub mod status;
pub mod watch;
use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell;
use clap::{Parser, Subcommand};
/// Bricht mit einer klaren Fehlermeldung ab, falls nicht als root aufgerufen. Mounten läuft
/// ausschließlich als root/System-Dienst (siehe [`smart_mount::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.
/// 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
/// [`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
/// (siehe sudo-ctdra); der `io::Error`-Rückgabewert ist ausschließlich der Fehlerfall (z. B.
/// `sudo` nicht gefunden).
pub(crate) fn require_root(context: &str) -> anyhow::Result<()> {
if !sudo_ctdra::is_run_as_root() {
anyhow::bail!("'{context}' requires root privileges (re-run with sudo).");
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(())
}
@@ -65,42 +73,39 @@ 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.
#[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
/// `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::Doctor { .. } => "doctor",
}
}
/// 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,
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,
Commands::Completions { shell } => {
clap_complete::generate(
shell,
&mut Cli::command(),
"smart-mount",
&mut std::io::stdout(),
);
Ok(())
}
}
}
+33 -6
View File
@@ -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::{self, Action};
fn select_pairs(
cfg: &smart_mount::config::AppConfig,
cfg: &crate::config::AppConfig,
name: Option<&str>,
all: bool,
) -> anyhow::Result<Vec<DrivePair>> {
@@ -26,11 +26,27 @@ pub async fn run_mount(name: Option<String>, 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?;
let mut had_failure = false;
for pair in &pairs {
let outcome = reconcile::reconcile_pair(pair, &cfg.settings, &creds).await;
print_outcome(&outcome);
if matches!(outcome.action, Action::Failed(_)) {
had_failure = true;
}
}
// Ohne dies wäre `smart-mount-mount.service` (siehe packaging/systemd/) beim Booten selbst
// dann "erfolgreich" (Exit-Code 0), wenn tatsächlich kein einziges Laufwerkspaar gemountet
// werden konnte - `systemctl status` würde den Fehlschlag also nie sichtbar machen. Analog
// zu `watch::run` unten.
if had_failure {
anyhow::bail!("at least one drive pair could not be mounted");
}
Ok(())
}
@@ -43,18 +59,29 @@ pub async fn run_unmount(name: Option<String>, all: bool) -> anyhow::Result<()>
println!("No matching drive pairs found.");
return Ok(());
}
logger_ctdra::info(
"mount",
&format!("unmount: processing {} drive pair(s)", pairs.len()),
);
let mut had_failure = false;
for pair in &pairs {
match reconcile::unmount_pair(pair, &cfg.settings).await {
Ok(_) => println!("{} ({}): unmounted", pair.name, pair.id),
Err(e) => println!("{} ({}): ERROR: {e}", pair.name, pair.id),
Err(e) => {
println!("{} ({}): ERROR: {e}", pair.name, pair.id);
had_failure = true;
}
}
}
if had_failure {
anyhow::bail!("at least one drive pair could not be unmounted");
}
Ok(())
}
pub(crate) fn print_outcome(outcome: &reconcile::ReconcileOutcome) {
use reconcile::Action;
let action_str = match &outcome.action {
Action::NoOp => "no change".to_string(),
Action::MountedLocal => "mounted local".to_string(),
-54
View File
@@ -1,54 +0,0 @@
//! `smart-mount service crontab [--remove]`.
use clap::Subcommand;
use smart_mount::config;
use smart_mount::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) => {
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 => println!("Cron entry removed."),
systemd::CronUninstallOutcome::NotPresent => {
println!("Nothing to remove - no cron entry was installed.")
}
}
Ok(())
}
+5 -5
View File
@@ -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<String>, 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))
+15 -4
View File
@@ -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")?;
@@ -19,6 +18,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");
+3 -1
View File
@@ -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);
}
+6
View File
@@ -10,6 +10,10 @@ pub fn load() -> Result<AppConfig> {
/// Fügt ein neues Laufwerkspaar hinzu (load-mutate-store in einem atomaren Schritt).
pub fn add_pair(pair: DrivePair) -> Result<AppConfig> {
logger_ctdra::debug(
"config",
&format!("writing new pair '{}' to config", pair.id),
);
Ok(config_ctdra::modify::<AppConfig, _>(|cfg| {
cfg.pairs.push(pair.clone());
})?)
@@ -18,6 +22,7 @@ pub fn add_pair(pair: DrivePair) -> Result<AppConfig> {
/// Ersetzt ein bestehendes Laufwerkspaar (Vergleich über `id`).
pub fn update_pair(pair: DrivePair) -> Result<AppConfig> {
let id = pair.id.clone();
logger_ctdra::debug("config", &format!("writing updated pair '{id}' to config"));
let updated = config_ctdra::modify::<AppConfig, _>(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<AppConfig> {
/// Entfernt ein Laufwerkspaar per ID.
pub fn remove_pair(id: &str) -> Result<AppConfig> {
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
+8
View File
@@ -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)
}
+2
View File
@@ -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<u8>, Vec<u8>)> {
logger_ctdra::debug("crypto", "encrypting credential data");
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key));
let mut nonce_bytes = [0u8; NONCE_LEN];
@@ -44,6 +45,7 @@ pub fn encrypt(plaintext: &[u8], key: &[u8; 32]) -> Result<(Vec<u8>, Vec<u8>)> {
/// Entschlüsselt einen zuvor mit [`encrypt`] erzeugten Ciphertext.
pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
logger_ctdra::debug("crypto", "decrypting credential data");
if nonce.len() != NONCE_LEN {
return Err(Error::Crypto(format!(
"invalid nonce length: expected {NONCE_LEN}, got {}",
+21
View File
@@ -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<Option<Credential>> {
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<Side>) -> 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
+1
View File
@@ -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<turso::Connection> {
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
+224 -26
View File
@@ -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<String> {
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<String>, detail: impl Into<String>) -> CheckResult {
/// Führt alle Checks gegen die aktuelle Konfiguration aus.
pub fn run_checks(cfg: &AppConfig) -> Vec<CheckResult> {
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));
}
}
@@ -64,7 +139,6 @@ pub fn run_checks(cfg: &AppConfig) -> Vec<CheckResult> {
&cfg.settings.mac2ip_binary,
"install mac2ip (private tool, see README)",
));
results.push(check_binary("nmap", "nmap", "install package 'nmap'"));
}
results
@@ -107,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 {
@@ -166,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(),
+1
View File
@@ -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;
+14 -2
View File
@@ -1,18 +1,30 @@
mod cli;
use std::process::ExitCode;
use clap::Parser;
use smart_mount::cli;
#[tokio::main]
async fn main() -> ExitCode {
smart_mount::config::init();
// Persistentes, gut auffindbares Log-Verzeichnis statt logger-ctdras Standard-Fallback
// (ein Ordner im System-Temp-Verzeichnis, siehe logger-ctdra-Doku) - smart-mount läuft
// ausschließlich als root/System-Dienst (siehe crate::systemd), daher immer derselbe,
// feste Pfad. Muss vor dem ersten Log-Aufruf stehen (siehe logger_ctdra::set_log_dir).
logger_ctdra::set_log_dir("/var/log/smart-mount");
let log_level = match smart_mount::config::pairs::load() {
Ok(cfg) => cfg.settings.log_level,
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 {
+13 -1
View File
@@ -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<PairLock> {
// 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.
+25 -9
View File
@@ -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::<Vec<_>>()
.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 => {
+6 -1
View File
@@ -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(())
}
+9
View File
@@ -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(())
}
+4
View File
@@ -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,
+19 -1
View File
@@ -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<Ipv4Addr> {
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<Ipv4Addr> {
})?;
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.
+9
View File
@@ -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([
+84 -12
View File
@@ -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<ReconcileOutcome> {
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<Ipv4Addr>) {
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<Side>,
cached_local_ip: Option<Ipv4Addr>,
) -> 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<Action> {
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)]
+103 -21
View File
@@ -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/<name>` 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 <unit>` 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<String> {
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<CronInstallOutcome> {
if !Path::new("/etc/cron.d").is_dir() {
return Ok(CronInstallOutcome::Unavailable);
@@ -67,6 +144,7 @@ pub fn install_cron(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
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 +174,10 @@ pub fn uninstall_cron() -> Result<CronUninstallOutcome> {
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)
}