31 Commits
Author SHA1 Message Date
DragonSlayer_14 7f5c8db6ce Merge pull request 'Merge dev in testing: Ref: Entfernt vollständige 32-Bit-Unterstützung (i686)' (#3) 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 13s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 27s
Security Scans / Trivy & OSV-Scanner (push) Successful in 44s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 45s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Successful in 12m11s
TruffleHog Secret Scan / TruffleHog (push) Successful in 10s
Reviewed-on: #3
2026-09-20 12:20:30 +00:00
DragonSlayer_14andClaude Sonnet 5 09cb728f30 Ref: Entfernt vollständige 32-Bit-Unterstützung (i686)
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 26s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 35s
Auto Patch-Version-Bump (Dev → Testing PR) / Erkenne relevante Änderungen im PR (pull_request) Successful in 9s
Auto Patch-Version-Bump (Dev → Testing PR) / Patch-Version erhöhen & auf Dev pushen (pull_request) Skipped
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 19s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 28s
Unit-Tests / Unit-Tests (pull_request) Successful in 1m52s
Baut, paketiert und dokumentiert nur noch x86_64 und aarch64;
i686-Cross-Compilation-Toolchain, -Targets und -Paketierungsschritte
in den Gitea-Workflows sowie die i686-Linker-Konfiguration entfallen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 14:14:38 +02:00
DragonSlayer_14 2f33e2202d Merge pull request 'Merge dev in testing: Neue Version 2.0.0 mit kompletter Überarbeitung' (#1) from dev into testing
Testing Build, Publish & Preview Release / Erkenne relevante Code-Änderungen (push) Successful in 10s
Unit-Tests / Unit-Tests (pull_request) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 20s
Auto-PR (Testing → Main) / Erstelle automatisch PR von testing nach main (push) Successful in 15s
Security Scans / Trivy & OSV-Scanner (push) Successful in 36s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 24s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 34s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Failing after 13m0s
Reviewed-on: #1
2026-09-20 11:44:44 +00:00
DragonSlayer_14 f71f43a7c0 Ref: Entfernt nicht verwendete Crate program-ctdra.
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 18s
TruffleHog Secret Scan / TruffleHog (pull_request) Successful in 28s
Security Scans / Trivy & OSV-Scanner (push) Successful in 45s
Auto Patch-Version-Bump (Dev → Testing PR) / Erkenne relevante Änderungen im PR (pull_request) Successful in 14s
Security Scans / Trivy & OSV-Scanner (pull_request) Successful in 51s
Auto Patch-Version-Bump (Dev → Testing PR) / Patch-Version erhöhen & auf Dev pushen (pull_request) Successful in 10s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 1m23s
Unit-Tests / Unit-Tests (pull_request) Successful in 2m33s
2026-09-20 13:40:48 +02:00
DragonSlayer_14andClaude Sonnet 5 e2e6c06bce Feature: Mountpoint heisst standardmaessig wie das Paar, frei ueberschreibbar
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 18s
Security Scans / Trivy & OSV-Scanner (push) Successful in 44s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 1m22s
Der Standard-Mountpunkt eines neuen Laufwerkspaares ist jetzt
'<mount_base_dir>/<Name des Paares>' (z. B. /run/media/NAS) statt
'/run/media/smart-mount/<uuid>' - beim Durchsuchen von /run/media ist sofort
erkennbar, worum es sich handelt, statt einer kryptischen ID. mount_base_dir
selbst ist standardmaessig /run/media (kein smart-mount-Zwischenordner mehr).

Der Name wird zu einem sicheren Verzeichnisnamen bereinigt (sanitize_dir_name)
und bei Kollision mit einem bestehenden Paar automatisch dedupliziert (-2,
-3, ...). Neuer Flag '--mount-point <pfad>' bei 'drive add'/'drive edit',
um den Mountpunkt komplett frei zu setzen (nicht-interaktiv per Flag,
interaktiv als editierbarer, vorbelegter Prompt) - unabhaengig von
mount_base_dir. 'drive edit' aendert den Mountpunkt weiterhin nur bei
expliziter Angabe (kein Prompt dafuer), mit einer Warnung, falls das Paar
gerade aktiv gemountet ist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:36:12 +02:00
DragonSlayer_14andClaude Sonnet 5 bd319523a7 Fix: Mount-Verzeichnisse bekommen explizite Rechte statt sich auf Umask zu verlassen
mount_base_dir (von allen Paaren gemeinsam genutzt) sowie die versteckten
Backing-Verzeichnisse eines Paares wurden bisher nur per create_dir_all()
angelegt, ohne eigenes chmod/chown - die tatsaechlichen Zugriffsrechte hingen
also allein vom Umask des root-Prozesses ab statt von einer bewussten
Entscheidung.

- activate_symlink() setzt den gemeinsamen Elternordner (mount_base_dir) jetzt
  hart auf 0755, unabhaengig vom Umask.
- reconcile::mount_side() chownt das jeweilige Backing-Verzeichnis eines
  Paares mit gesetztem owner_user auf diesen Nutzer und setzt 0700 (neue
  Funktion target::ensure_owner_only_dir) - insbesondere fuer NFS relevant,
  wo es (anders als CIFS/WebDAV) keine uid=/gid=-Mount-Option gibt, die den
  Zugriff nach dem Mounten sonst regeln wuerde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:34:06 +02:00
DragonSlayer_14andClaude Sonnet 5 f0c0a69b9c Feature: Eingerichtetes Laufwerkspaar gehoert automatisch dem einrichtenden sudo-Nutzer
'smart-mount drive add' setzt 'owner_user' jetzt standardmaessig auf den
Nutzer, in dessen Namen 'sudo' aufgerufen wurde ($SUDO_USER) - wer ein Paar
einrichtet, bekommt also ohne weiteres Zutun vollen Zugriff darauf (uid/gid).
Ueberschreibbar per '--owner-user <name>' (nicht-interaktiv) oder im
interaktiven Prompt, der jetzt auch den konkreten Nutzernamen nennt und dort
abgelehnt oder auf einen anderen Namen geaendert werden kann. 'drive edit'
bleibt unveraendert (Default bleibt der bisherige Owner).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:32:45 +02:00
DragonSlayer_14andClaude Sonnet 5 c6526e2130 Refactor: Systemd-Dienst laeuft nur noch systemweit, Paketierung uebernimmt Installation
SmartMount richtet sich beim Installieren des .deb/.rpm/.pkg.tar.zst-Pakets
automatisch als System-systemd-Dienst ein (statische Units unter
packaging/systemd/, aktiviert/deaktiviert ueber postinst/postrm bzw. die
generate-rpm-/Arch-Paketierungs-Skriptlets) und entfernt sich beim
Deinstallieren wieder - 'smart-mount service install/uninstall' faellt aus
der CLI, 'smart-mount service crontab' bleibt als manueller Fallback fuer
Systeme ohne systemd.

Der bisherige User-Scope (MountContext::User, unprivilegiertes Mounten ueber
/etc/fstab, 'setup fstab', systemd --user) faellt komplett weg: smart-mount
mountet jetzt jedes konfigurierte Paar immer direkt als root, unabhaengig
davon, ob/fuer welchen Nutzer 'owner_user' gesetzt ist. Damit einher gehen:

- Eine einzige, systemweite Konfiguration (/etc/smart-mount/config.toml)
  statt Root-/Nutzerpfad-Umschaltung - 'drive add/edit/remove' sowie
  'mount'/'unmount'/'watch' verlangen deshalb jetzt explizit Root.
- Der Master-Schluessel fuer die Zugangsdaten-Verschluesselung kommt nur noch
  aus der Schluesseldatei (kein OS-Keyring mehr moeglich, da nie unprivilegiert
  ausgefuehrt) - die 'keyring'-Abhaengigkeit entfaellt.
- Der Cron-Fallback schreibt nur noch /etc/cron.d/smart-mount (immer root),
  da eine persoenliche Nutzer-Crontab ohnehin nie mounten koennte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:32:02 +02:00
Gitea-Bot 17c1f6d2ba Style: Automatische Formatierung & Clippy-Fixes
Security Scans / Trivy & OSV-Scanner (push) Successful in 28s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
TruffleHog Secret Scan / TruffleHog (push) Successful in 18s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 36s
2026-09-19 19:54:19 +00:00
DragonSlayer_14andClaude Sonnet 5 05a4a908df Refactor: fstab nutzt Side::as_str() statt einer eigenen Kopie
TruffleHog Secret Scan / TruffleHog (push) Successful in 21s
Security Scans / Trivy & OSV-Scanner (push) Successful in 37s
Code Quality (Auto-Format & Clippy-Fix) / Formatierung & Clippy automatisch beheben (push) Successful in 2m14s
Testing Build, Publish & Preview Release / Build, Publish Packages (Testing) & Create Preview Release (push) Skipped
fstab::side_str() bildete exakt dieselbe Zuordnung wie das bereits
importierte db::credentials::Side::as_str() nach - eine kuenftige
Aenderung der String-Repraesentation (oder ein dritter Side-Wert)
haette leicht dazu fuehren koennen, dass die fstab-Tag-Zeilen
unbemerkt von der ueberall sonst genutzten Darstellung abweichen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:45:37 +02:00
DragonSlayer_14andClaude Sonnet 5 75a23646dd Fix: Mount-Unit nutzt pro Scope das richtige WantedBy und network-online.target
mount_service_unit() schrieb bislang unabhaengig vom Installations-Scope
immer "WantedBy=multi-user.target" - fuer eine User-Scope-Installation
(systemctl --user) ist dieses Ziel falsch/wirkungslos, da es dort keine
Entsprechung hat; die Unit haette nie zuverlaessig automatisch gestartet
werden koennen. Nimmt jetzt den Scope entgegen und setzt fuer System
"multi-user.target", fuer User "default.target".

Ebenso wartete die Unit unabhaengig vom Scope auf network-online.target -
auch das ist ein Ziel des System-Managers ohne sinnvolle Entsprechung
unter systemctl --user. Die Ordnungsabhaengigkeit (After=/Wants=) wird
jetzt nur noch im System-Kontext gesetzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:45:14 +02:00
DragonSlayer_14andClaude Sonnet 5 5f8b81ee3e Fix: mac2ip-Aufloesung hat jetzt ein Timeout gegen unbegrenztes Haengen
mac2ip::resolve() rief das externe mac2ip-Binary (das intern einen
nmap-Scan ausloesen kann) ohne jedes Timeout auf - anders als mount/umount,
die bereits ueber run_with_timeout/MOUNT_TIMEOUT_SECS gegen einen haengenden
Subprozess abgesichert sind. Ein nicht mehr reagierendes mac2ip/nmap
haette damit smart-mount watch/status unbegrenzt blockieren koennen.
Der Aufruf laeuft jetzt (wie mount/umount) ueber die coreutils timeout(1),
mit einer klaren Fehlermeldung bei Ueberschreitung.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:44:49 +02:00
DragonSlayer_14andClaude Sonnet 5 d807548b43 Fix: is_reachable() akzeptiert 401/403, aber nicht dauerhafte 5xx-Fehler
Der HTTP-Zweig von is_reachable() nutzte curl --fail, was einen HTTP-Server,
der auf das HEAD mit 401/403 (Auth erforderlich, aber erreichbar) antwortet,
faelschlich als nicht erreichbar meldete - relevant fuer WebDAV-Cloud-Seiten,
die Auth verlangen. Der tatsaechliche Statuscode wird jetzt ausgelesen: nur
ein Serverfehler (5xx) gilt als nicht erreichbar, da ein dauerhaft
5xx-antwortender Server (z. B. ein kaputter Reverse-Proxy) sonst bei jedem
watch-Durchlauf einen vollen, letztlich erfolglosen Umschaltversuch
(inkl. MOUNT_TIMEOUT_SECS-Wartezeit) ausloesen wuerde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:44:26 +02:00
DragonSlayer_14andClaude Sonnet 5 38c95cc4bf Perf: Erreichbarkeitspruefung parallelisiert, lokale IP nur einmal aufgeloest
watch_once() rief reconcile_pair() sequenziell fuer jedes Paar auf - bei
mehreren/langsamen Paaren (Ping-/curl-Timeouts von mehreren Sekunden pro
Seite) wuchs die Laufzeit eines watch-Durchlaufs dadurch linear mit der
Anzahl konfigurierter Paare, obwohl die pro-Paar-Sperre (siehe lock.rs)
genau fuer parallele Reconciles gedacht ist. Die Erreichbarkeitspruefungen
laufen jetzt ueber spawn_blocking parallel; das eigentliche Mounten/
Aushaengen bleibt sequenziell (teilt sich CredentialStore/Dateizustand).

Ausserdem loeste ein Reconcile-Durchlauf fuer ein MAC-adressiertes Paar
dieselbe Adresse bislang zweimal ueber mac2ip auf: einmal fuer die
Erreichbarkeitspruefung (check_local_reachable), ein zweites Mal beim
tatsaechlichen Mounten (build_target -> local_source). check_local_reachable
gibt die aufgeloeste IP jetzt zurueck und reicht sie bis zu mount_side()
durch (target::build_target_with_cached_local_ip); status.rs loest die
lokale Adresse ebenfalls nur noch einmal pro Paar auf und nutzt sie fuer
Quelle UND Erreichbarkeitsanzeige.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:43:59 +02:00
DragonSlayer_14andClaude Sonnet 5 e05d1174e7 Fix: Aus-/Umhaengen behandelt Fehler robuster
- unmount_side() verschluckte einen fehlgeschlagenen build_target()
  kommentarlos und mountete stattdessen einen leeren Platzhalter aus -
  z. B. wenn owner_user zwischenzeitlich vom System geloescht wurde.
  Wird jetzt geloggt, bevor mit denselben Best-effort-Defaults
  weitergemacht wird.
- unmount_pair() brach beim ersten `?` (auch bei einem reinen
  is_mounted()-Pruepffehler) komplett ab, statt die zweite Seite
  trotzdem zu versuchen - ein echt gemounteter Cloud-Anteil blieb dann
  unangetastet, wenn schon die Local-Pruefung fehlschlug. Beide Seiten
  werden jetzt unabhaengig voneinander versucht; der erste Fehler wird
  erst nach beiden Versuchen zurueckgegeben.
- target::active_side() erkennt "die" aktive Seite ausschliesslich ueber
  den sichtbaren Symlink - ein Mount auf der jeweils anderen Seite (z. B.
  nach einem abgebrochenen Umschalten oder externem manuellen Mount)
  blieb dadurch fuer status/watch unsichtbar und wurde nie automatisch
  wieder ausgehaengt. cleanup_orphaned_mounts() raeumt einen solchen
  verwaisten Mount jetzt bei jedem Reconcile-Durchlauf best-effort auf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:43:10 +02:00
DragonSlayer_14andClaude Sonnet 5 af88a306f9 Fix: Paar-Sperre ist jetzt prozessuebergreifend statt nur prozessintern
mount::lock::acquire() war ein rein prozessinterner Mutex<HashMap<...>>.
smart-mount ist aber ein One-Shot-CLI (siehe reconcile-Moduldoku) - jeder
Aufruf ist ein eigener Prozess mit eigener, leerer Registry. Ein manuelles
`unmount --name X` waehrend ein gleichzeitig laufender systemd-Timer-
`watch` fuer dasselbe Paar arbeitet, kollidierte dadurch ungebremst statt
serialisiert zu werden.

acquire() sperrt jetzt ueber eine `flock(2)`-Datei pro Paar
(`std::fs::File::lock()`, seit Rust 1.89 Teil der Standardbibliothek) in
einem root- bzw. XDG_RUNTIME_DIR-basierten Verzeichnis - damit blockieren
sich zwei Prozesse fuer dasselbe Paar tatsaechlich gegenseitig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:41:49 +02:00
DragonSlayer_14andClaude Sonnet 5 c757d19eae Fix: local_source() gibt wieder ein Result zurueck
mount::target::local_source() gab bei fehlgeschlagener Adressaufloesung
(mac2ip findet die MAC-Adresse nicht) bisher den Platzhalter-String
"unresolved" zurueck und formatierte daraus eine syntaktisch gueltige,
aber sinnlose Mount-Quelle - statt den Fehler an den Aufrufer
durchzureichen. mount::cleanup_side_credentials() (WebDAV-Zweig)
erwartet bereits seit 690f727 ein Result von lokal_source(), um einen
Aufloesungsfehler zu loggen statt ihn zu verschlucken; ohne diese
Aenderung war das im Repository committete HEAD dadurch nicht
kompilierbar. local_source() gibt jetzt ein Result<String> zurueck und
reicht den Aufloesungsfehler ueber `?` durch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 21:41:06 +02:00
DragonSlayer_14andClaude Sonnet 5 629959ad41 Fix: status verwendet keinen Fake-Hostnamen fuer die Erreichbarkeitspruefung
local_reachable() reichte bei fehlgeschlagener Adressaufloesung (MAC->IP)
den Platzhalter-String "unresolved" an network::is_reachable() weiter -
das loeste dort einen sinnlosen ping-Aufruf gegen einen nicht
existierenden Hostnamen aus, statt korrekt "nicht erreichbar" zu
melden. Gibt jetzt direkt false zurueck, wenn die Adresse nicht
aufgeloest werden kann.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:09:01 +02:00
DragonSlayer_14andClaude Sonnet 5 c8c3409b98 Fix: switch_to() haengt bei fehlgeschlagener Symlink-Aktivierung wieder aus
Schlug target::activate_symlink() nach dem Mounten der neuen Seite
fehl, blieb diese neu gemountete Seite unbemerkt gemountet: der
sichtbare Symlink zeigt weiterhin auf old_active, wodurch der naechste
Reconcile-Durchlauf new_side nie wieder aushaengt - ein dauerhaft
verwaister Mount. switch_to() haengt die neue Seite jetzt wieder aus,
falls die Symlink-Aktivierung fehlschlaegt, bevor der Fehler
weitergereicht wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:07:27 +02:00
DragonSlayer_14andClaude Sonnet 5 fa1592e18e Fix: cifs-Credentials-Datei lehnt Newlines und leeres Passwort ab
write_credentials_file() schrieb username/password/domain ungeprüft
in die zeilenbasierte (key=value) Credentials-Datei für mount.cifs -
ein eingebettetes Newline in einem Feld hätte eine zusätzliche, nicht
vorgesehene Zeile einschleusen können (z. B. ein überraschendes
domain= oder ein zweites password=, das das eigentliche
überschreibt). Felder mit Newline/Carriage-Return sowie ein leeres
Passwort werden jetzt vor dem Schreiben abgelehnt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:06:44 +02:00
DragonSlayer_14andClaude Sonnet 5 690f727600 Fix: WebDAV-Credential-Cleanup loggt Auflösungsfehler statt zu schweigen
cleanup_side_credentials() gab bei einem Fehler beim Auflösen der
lokalen Quelladresse (für den WebDAV-Zweig) kommentarlos auf, statt
den Fehler zu loggen - eine zurückbleibende Klartext-davfs2-secrets-
Zeile für ein entferntes Paar blieb dadurch spurlos zurück. Fehler
wird jetzt mit Kontext geloggt, bevor die Funktion (weiterhin
best-effort) zurückkehrt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:06:37 +02:00
DragonSlayer_14andClaude Sonnet 5 6695a61e04 Fix: default_mount_base_dir() ohne festen "user"-Platzhalter-Fallback
Bei fehlendem $USER (z. B. unter systemd --user) fiel die Funktion
auf den festen String "user" zurück - zwei verschiedene reale Nutzer
hätten sich dadurch denselben Mountpoint-Namensraum
/run/media/user/smart-mount geteilt, obwohl die Aufteilung nach
Nutzername genau das verhindern soll. Fällt jetzt zuerst auf
`id -un`, dann auf eine UID-basierte Kennung zurück, bevor als
letzter Ausweg weiterhin "user" verwendet wird.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:06:28 +02:00
DragonSlayer_14andClaude Sonnet 5 d4d80be1bf Fix: TOCTOU in remove_pair() bei nebenläufigem Schreiber
Die "vorher"-Länge wurde über einen separaten, vorgelagerten load()
ermittelt statt innerhalb desselben atomaren modify()-Aufrufs -
zwischen beiden Aufrufen konnte ein nebenläufiger Schreiber die
Paarliste ändern, was zu einem falschen PairNotFound trotz
erfolgreichem Entfernen führen konnte. Der Vorher/Nachher-Vergleich
läuft jetzt vollständig innerhalb des modify()-Closures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:06:20 +02:00
DragonSlayer_14andClaude Sonnet 5 2b3514ab66 Fix: mountinfo-Vergleich entschlüsselt oktal escapte Pfade
/proc/self/mountinfo escaped Leerzeichen/Tab/Newline/Backslash im
Mountpoint-Feld oktal (siehe man 5 proc), der Vergleich erfolgte aber
gegen den unescapten, tatsächlichen Pfad - ein Mountpoint mit z. B.
einem Leerzeichen wurde dadurch nie als gemountet erkannt.
parse_mountinfo_source() entschlüsselt das Feld jetzt vor dem
Vergleich (byteweise, um mehrbytige UTF-8-Zeichen im Pfad nicht zu
zerlegen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:06:11 +02:00
DragonSlayer_14andClaude Sonnet 5 bc324dd4b7 Fix: drive remove/add - kein blockierender Unmount, kein stiller Fallback
`drive remove` brach bei einem Unmount-Fehler (z. B. fehlende Rechte,
weil das Paar in einem anderen Kontext gemountet wurde) komplett ab,
bevor das Paar aus der Konfiguration entfernt wurde - der Nutzer kam
dann nicht mehr an sein eigenes `drive remove` heran. Der Unmount
läuft jetzt best-effort (wie das bereits bestehende
cleanup_credentials), ein Fehler wird geloggt statt zu blockieren.

`drive add` fiel bei einem fehlgeschlagenen Konfigurations-Laden
(z. B. korrupte Datei - eine fehlende Datei liefert bereits
Standardwerte) still auf GlobalSettings::default() zurück. Das hätte
nicht nur einen abweichenden mount_base_dir ignoriert, sondern auch
das nachfolgende add_pair() (das intern selbst neu lädt) auf
denselben kaputten Zustand treffen lassen - mit dem Risiko, dass alle
bestehenden Paare durch die eine neue Konfiguration ersetzt werden.
Ein Ladefehler bricht jetzt früh mit einer klaren Fehlermeldung ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:06:03 +02:00
DragonSlayer_14andClaude Sonnet 5 bb9290c113 Fix: fstab-Setup - Mehrnutzer-sicheres Merging statt Komplett-Ersetzen
setup() ersetzte den gemeinsam genutzten verwalteten Block in
/etc/fstab bei jedem Lauf komplett anhand der aktuell geladenen
(nutzerspezifischen) Konfiguration. Führte ein zweiter Nutzer
`sudo smart-mount setup fstab` für sein eigenes Konto aus, wurden
dadurch die zuvor vom ersten Nutzer installierten Zeilen
stillschweigend gelöscht. write_managed_block() führt den Block jetzt
zeilenweise zusammen (jede Zeile trägt einen Tag-Kommentar mit
Paar-ID/Seite/Besitzer): eigene, jetzt gelöschte Paare werden
entfernt, Zeilen anderer Nutzer bleiben unangetastet.

Zusätzlich behoben:
- render_managed_block() brach beim ersten Paar, dessen Neuberechnung
  fehlschlug (z. B. ein gerade offline-MAC-adressiertes Gerät), die
  gesamte Blockerstellung ab - inklusive aller anderen, gesunden
  Paare. Fehlschläge werden jetzt pro Paar/Seite geloggt und
  übersprungen, die bestehende Zeile bleibt in diesem Fall erhalten.
- /etc/fstab wurde per direktem std::fs::write (Trunkieren) statt
  atomar geschrieben - ein Absturz mitten im Schreiben konnte die
  Datei in einem leeren/kaputten Zustand zurücklassen. Alle Schreib-
  zugriffe (setup, teardown) laufen jetzt über Temp-Datei + rename.
- user_home_dir() erriet bei fehlgeschlagener getent/passwd-Auflösung
  stillschweigend "/home/<user>" - loggt jetzt eine Warnung.

util.rs bekommt dafür extract_managed_block() als Gegenstück zu
strip_managed_block().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:05:52 +02:00
DragonSlayer_14andClaude Sonnet 5 efabfdf7b1 Fix: davfs2-secrets-Datei - Quoting und exaktes URL-Matching
write_secrets_entry()/remove_secrets_entry() identifizierten eine
Zeile per l.trim_start().starts_with(url) - eine URL, die textlich
Präfix einer anderen ist (z. B. .../dav vs. .../dav-archive), traf
dadurch auf beide Zeilen und konnte das falsche Paar korrumpieren
oder dessen Zugangsdaten löschen. Matching erfolgt jetzt exakt gegen
das erste Feld der Zeile.

Passwörter/Nutzernamen mit Leerraum wurden zudem unquotiert
geschrieben - davfs2 trennt Felder per Leerraum und hätte ein
Passwort mit Leerzeichen am ersten Leerzeichen abgeschnitten. Werte
mit Leerraum werden jetzt in Anführungszeichen gefasst (siehe
man davfs2.conf). Leere Nutzername/Passwort-Felder werden zusätzlich
vor dem Schreiben abgelehnt statt eine kaputte Zeile zu erzeugen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:05:38 +02:00
DragonSlayer_14andClaude Sonnet 5 706494d106 Fix: Verhindert Datenverlust bei Master-Key-Backend-Wechsel und -Race
resolve_master_key() erzeugte bei vorübergehend nicht erreichbarem
OS-Keyring (z. B. unter systemd --user ohne D-Bus-Secret-Service)
stillschweigend einen NEUEN Datei-Schlüssel statt den zuvor genutzten
Keyring-Schlüssel weiterzuverwenden - bereits verschlüsselte
Zugangsdaten wurden dadurch dauerhaft unentschlüsselbar. Welches
Backend (Keyring oder Datei) genutzt wird, wird jetzt beim ersten
Aufruf in einer Marker-Datei festgehalten und danach konsistent
wiederverwendet.

Zusätzlich: sowohl die Datei- als auch die Keyring-Variante von
load_or_create() gingen bei zwei gleichzeitigen ersten Aufrufen
(Race) unterschiedliche, sich widersprechende Schlüssel ein - beide
lesen jetzt den tatsächlich persistierten Schlüssel zurück, statt
blind ihren eigenen zu verwenden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LjyzpGWECyKSBWz5DtkTGz
2026-09-15 23:05:29 +02:00
DragonSlayer_14andClaude Sonnet 5 2a4619c8e8 Docs: Dokumentiert SmartMount 2.0.0 in der README
Beschreibt die neue Architektur (Laufwerkspaare, Mount-Backends,
verschlüsselte Zugangsdaten, mac2ip-Integration), Voraussetzungen,
Setup-Ablauf (setup fstab, service install) sowie die vollständige
CLI-Referenz.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZ3VzCWgbQRMyFEEKPvnZz
2026-09-15 00:50:30 +02:00
DragonSlayer_14andClaude Sonnet 5 b64b81a5c1 Feat: Kompletter Rewrite von SmartMount 2.0.0
Ersetzt die alte Single-Paar-Implementierung durch eine modulare
Architektur, die beliebig viele lokale/Cloud-Laufwerkspaare verwaltet
und automatisch zwischen ihnen umschaltet:

- Dynamisch dispatchte Mount-Backends für WebDAV (davfs2), SMB/CIFS und
  NFS, inkl. NFS-"soft"-Resilienz-Defaults gegen unbegrenztes Hängen bei
  nicht erreichbaren Servern.
- Verschlüsselte Zugangsdaten-Ablage in einer eingebetteten Turso-DB
  (AES-256-GCM), Master-Key aus OS-Keyring mit Datei-Fallback.
- Lokal-Adressierung per IP oder MAC (Auflösung über das externe Tool
  `mac2ip`).
- Symlink-basiertes Umschalten zwischen zwei eindeutigen Backing-
  Verzeichnissen pro Paar, statt zweier fstab-Zeilen auf denselben
  Mountpoint (siehe `mount/target.rs` für die Begründung).
- Einmaliges root-Setup (`setup fstab`) für unprivilegierte User-
  Kontext-Mounts inkl. automatischer Verzeichnis-Ownership unter
  `/run/media`.
- systemd-Units (System/User) mit automatischem Cron-Fallback für
  Systeme ohne systemd, inkl. sauberem Uninstall aller Artefakte.
- `doctor`-Diagnose, Shell-Completions, JSON-Ausgabe für
  Skript-Automatisierung.
- Alle Terminal-Ausgaben (Logs, Fehler, Prompts, --help) auf Englisch,
  Code-Kommentare und Doku weiterhin auf Deutsch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZ3VzCWgbQRMyFEEKPvnZz
2026-09-15 00:50:20 +02:00
DragonSlayer_14andClaude Sonnet 5 cd290f7f5b Chore: Fügt CI/Tooling-Vorlagen aus dem persönlichen Rust-Projekt-Template hinzu
Ersetzt die alten, projektspezifischen Metadaten (Lizenzjahr/-inhaber,
.gitignore) durch die Standardvorlage: Gitea-Workflows für CI/Security/
Renovate, Qodana-Konfiguration, Packaging-Skripte sowie IDE-/Cargo-
Registry-Einstellungen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FZ3VzCWgbQRMyFEEKPvnZz
2026-09-15 00:49:57 +02:00
68 changed files with 10197 additions and 2323 deletions
+12
View File
@@ -0,0 +1,12 @@
[registry]
default = "gitea"
[registries.gitea]
index = "sparse+https://gitea.creative-dragonslayer.de/api/packages/Rust-Crates/cargo/" # Sparse index
# index = "https://gitea.creative-dragonslayer.de/Rust-Crates/_cargo-index.git" # Git
[net]
git-fetch-with-cli = true
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
+54
View File
@@ -0,0 +1,54 @@
name: Code Quality (Auto-Format & Clippy-Fix)
on:
push:
branches:
- dev
workflow_dispatch:
jobs:
fix:
name: Formatierung & Clippy automatisch beheben
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
ref: ${{ gitea.ref_name || github.ref_name }}
token: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
- name: Install Rust Toolchain
uses: actions-rust-lang/setup-rust-toolchain@v2
with:
toolchain: stable
components: clippy, rustfmt
cache: false
- name: Cache Cargo-Abhängigkeiten & Build-Artefakte
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-
- name: Formatierung automatisch beheben
run: cargo fmt
- name: Clippy-Fixes automatisch anwenden
run: cargo clippy --fix --allow-dirty --allow-staged --all-targets
- name: Änderungen committen & pushen
run: |
if [ -n "$(git status --porcelain)" ]; then
git config user.name "Gitea-Bot"
git config user.email "no-reply@creativedragonslayer.de"
git add -A
git commit -m "Style: Automatische Formatierung & Clippy-Fixes"
git push origin HEAD:${{ gitea.ref_name || github.ref_name }}
else
echo "Keine Formatierungs- oder Clippy-Änderungen."
fi
+249
View File
@@ -0,0 +1,249 @@
name: Main Release & Publish
on:
push:
branches:
- main
jobs:
detect-changes:
name: Erkenne relevante Code-Änderungen
runs-on: ubuntu-latest
outputs:
code_changed: ${{ steps.filter.outputs.code }}
steps:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Prüfe auf Änderungen am Programmcode
uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'scripts/**'
- '.cargo/**'
- '.gitea/workflows/main.yaml'
release-and-publish:
name: Build, Publish Packages (Stable) & Create Release
needs: detect-changes
if: needs.detect-changes.outputs.code_changed == 'true'
runs-on: ubuntu-latest
env:
CARGO_BINSTALL_VERSION: "1.23.0"
CARGO_DEB_VERSION: "3.8.0"
CARGO_GENERATE_RPM_VERSION: "0.21.0"
steps:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Install Rust Toolchain
uses: actions-rust-lang/setup-rust-toolchain@v2
with:
toolchain: stable
cache: false
- name: Cache Cargo-Abhängigkeiten & Build-Artefakte
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-
- name: Alte Paketierungs-Ausgaben aus dem Cache entfernen
run: rm -rf target/debian target/generate-rpm target/arch
- name: Install Cross-Compilation Toolchains (apt)
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Ermittle Rust-Version für Rustup-Target-Cache-Key
run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV"
- name: Cache Rustup Cross-Compilation-Targets
id: cache-rustup-targets
uses: actions/cache@v6
with:
path: |
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/aarch64-unknown-linux-gnu
key: rustup-targets-${{ runner.os }}-${{ env.RUST_VERSION }}
- name: Add Rust Cross-Compilation Targets
if: steps.cache-rustup-targets.outputs.cache-hit != 'true'
run: rustup target add aarch64-unknown-linux-gnu
- name: PATH um Cargo-bin-Verzeichnis ergänzen
run: |
mkdir -p ~/.cargo/bin
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Cache Packaging-Tools (cargo-binstall, cargo-deb, cargo-generate-rpm)
id: cache-packaging-tools
uses: actions/cache@v6
with:
path: |
~/.cargo/bin/cargo-binstall
~/.cargo/bin/cargo-deb
~/.cargo/bin/cargo-generate-rpm
key: packaging-tools-${{ runner.os }}-${{ env.CARGO_BINSTALL_VERSION }}-${{ env.CARGO_DEB_VERSION }}-${{ env.CARGO_GENERATE_RPM_VERSION }}
- name: Install Packaging Tools (Prebuilt Binaries)
if: steps.cache-packaging-tools.outputs.cache-hit != 'true'
run: |
curl -fsSL "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" | tar -xz -C ~/.cargo/bin
~/.cargo/bin/cargo-binstall -y --no-symlinks "cargo-deb@${CARGO_DEB_VERSION}" "cargo-generate-rpm@${CARGO_GENERATE_RPM_VERSION}"
- name: Run Tests
run: |
cargo test
- name: Build Release Binaries
run: |
cargo build --release --target x86_64-unknown-linux-gnu
cargo build --release --target aarch64-unknown-linux-gnu
- name: Determine Build Number
id: build_num
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
run: |
BUILD_NUM=$(python3 scripts/get-build-number.py)
echo "build_number=${BUILD_NUM}" >> $GITHUB_OUTPUT
echo "BUILD_NUMBER=${BUILD_NUM}" >> $GITHUB_ENV
echo "Ermittelte Build-Nummer: ${BUILD_NUM}"
- name: Build Debian Packages (.deb)
run: |
cargo deb --target x86_64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
cargo deb --target aarch64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
- name: Build Fedora / RPM Packages (.rpm)
run: |
mkdir -p target/generate-rpm
cargo generate-rpm --target x86_64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
cargo generate-rpm --target aarch64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
- name: Build Arch Linux Packages (.pkg.tar.zst)
run: |
python3 scripts/package-arch.py --target x86_64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
python3 scripts/package-arch.py --target aarch64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
- name: Publish Packages to Gitea Package Registry
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
run: |
echo "Veröffentliche Debian-Paket (Distribution: stable, Component: main)..."
for deb in target/debian/*.deb; do
[ -f "$deb" ] || continue
curl -f -s -S -X PUT \
-H "Authorization: token ${TOKEN}" \
--upload-file "$deb" \
"${GITEA_URL}/api/packages/${REPO_OWNER}/debian/pool/stable/main/upload"
done
echo "Veröffentliche Fedora/RPM-Paket (Gruppe: stable)..."
for rpm in target/generate-rpm/*.rpm; do
[ -f "$rpm" ] || continue
curl -f -s -S -X PUT \
-H "Authorization: token ${TOKEN}" \
--upload-file "$rpm" \
"${GITEA_URL}/api/packages/${REPO_OWNER}/rpm/stable/upload"
done
echo "Veröffentliche Arch Linux-Paket (Repository: stable)..."
for pkg in target/arch/*.pkg.tar.zst; do
[ -f "$pkg" ] || continue
curl -f -s -S -X PUT \
-H "Authorization: token ${TOKEN}" \
--upload-file "$pkg" \
"${GITEA_URL}/api/packages/${REPO_OWNER}/arch/stable"
done
- name: Create Gitea Release and Upload Assets
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
REPO_NAME: ${{ gitea.repository_name || github.event.repository.name }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
TAG_NAME="v${VERSION}"
RELEASE_TITLE="Release ${TAG_NAME}"
RELEASE_NOTES="Automatisches Release für ${REPO_NAME} ${VERSION}."
echo "Erstelle oder hole Release für Tag ${TAG_NAME} in ${REPO}..."
GET_RESP=$(curl -s -w "\n%{http_code}" \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG_NAME}")
HTTP_CODE=$(echo "$GET_RESP" | tail -n1)
BODY=$(echo "$GET_RESP" | sed '$d')
RELEASE_ID=""
if [ "$HTTP_CODE" -eq 200 ]; then
RELEASE_ID=$(echo "$BODY" | jq -r '.id // empty' 2>/dev/null || echo "$BODY" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
echo "Bestehendes Release gefunden (ID: ${RELEASE_ID})."
else
echo "Erstelle neues Release ${TAG_NAME}..."
CREATE_PAYLOAD=$(cat <<EOF
{
"tag_name": "${TAG_NAME}",
"target_commitish": "main",
"name": "${RELEASE_TITLE}",
"body": "${RELEASE_NOTES}",
"draft": false,
"prerelease": false
}
EOF
)
CREATE_RESP=$(curl -f -s -S -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$CREATE_PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases")
RELEASE_ID=$(echo "$CREATE_RESP" | jq -r '.id // empty' 2>/dev/null || echo "$CREATE_RESP" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
echo "Neues Release erstellt (ID: ${RELEASE_ID})."
fi
if [ -z "$RELEASE_ID" ]; then
echo "Fehler: Release-ID konnte nicht ermittelt werden!"
exit 1
fi
EXISTING_ASSETS_JSON=$(curl -s \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" || echo "[]")
for file in target/debian/*.deb target/generate-rpm/*.rpm target/arch/*.pkg.tar.zst; do
[ -f "$file" ] || continue
filename="$(basename "$file")"
echo "Lade Release-Asset hoch: $filename"
ASSET_ID=$(echo "$EXISTING_ASSETS_JSON" | jq -r --arg name "$filename" '.[]? | select(.name == $name) | .id' 2>/dev/null | head -n1 || true)
if [ -n "$ASSET_ID" ] && [ "$ASSET_ID" != "null" ]; then
echo "Lösche altes Asset mit ID ${ASSET_ID}..."
curl -s -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" || true
fi
curl -f -s -S -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
echo "Asset ${filename} erfolgreich hochgeladen."
done
+27
View File
@@ -0,0 +1,27 @@
name: Renovate
on:
schedule:
- cron: "0 * * * *"
workflow_dispatch:
jobs:
renovate:
name: Dependency-Updates prüfen & Pull Requests erstellen
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:44.82.0
steps:
- name: Renovate ausführen
run: renovate
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: ${{ gitea.server_url || github.server_url }}/api/v1/
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_REPOSITORIES: ${{ gitea.repository || github.repository }}
RENOVATE_AUTODISCOVER: "false"
RENOVATE_ALLOW_CUSTOM_CRATE_REGISTRIES: "true"
RENOVATE_GIT_AUTHOR: "Renovate Bot <renovate-bot@creativedragonslayer.de>"
RENOVATE_HOST_RULES: >-
[{"hostType":"cargo","matchHost":"${{ gitea.server_url || github.server_url }}","token":"${{ secrets.RENOVATE_TOKEN }}"}]
GITHUB_COM_TOKEN: ${{ secrets.GH_RENOVATE_TOKEN }}
LOG_LEVEL: info
+95
View File
@@ -0,0 +1,95 @@
name: Security Scans
on:
push:
branches:
- main
- testing
- dev
pull_request:
schedule:
- cron: "0 5 * * 1"
workflow_dispatch:
jobs:
security-scan:
name: Trivy & OSV-Scanner
runs-on: ubuntu-latest
env:
TRIVY_VERSION: "0.74.0"
OSV_SCANNER_VERSION: "2.5.1"
steps:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Lokales bin-Verzeichnis zum PATH hinzufügen
run: |
mkdir -p "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Cache Trivy-Binary
id: cache-trivy
uses: actions/cache@v6
with:
path: ~/.local/bin/trivy
key: trivy-bin-${{ runner.os }}-${{ env.TRIVY_VERSION }}
- name: Install Trivy
if: steps.cache-trivy.outputs.cache-hit != 'true'
run: |
curl -fsSL -o trivy.tar.gz \
"https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
tar -xzf trivy.tar.gz trivy
chmod +x trivy
mv trivy "$HOME/.local/bin/trivy"
rm -f trivy.tar.gz
- name: Ermittle Cache-Datum für Trivy-DB
run: echo "CACHE_DATE=$(date -u +%Y-%m-%d)" >> "$GITHUB_ENV"
- name: Cache Trivy-Schwachstellen-Datenbank
uses: actions/cache@v6
with:
path: ~/.cache/trivy
key: trivy-db-${{ runner.os }}-${{ env.CACHE_DATE }}
restore-keys: |
trivy-db-${{ runner.os }}-
- name: Run Trivy Scanner
run: |
trivy fs \
--scanners vuln,secret,misconfig \
--severity CRITICAL,HIGH \
--format json \
--output trivy-results.json \
--exit-code 0 \
.
- name: Cache OSV-Scanner-Binary
id: cache-osv-scanner
uses: actions/cache@v6
with:
path: ~/.local/bin/osv-scanner
key: osv-scanner-bin-${{ runner.os }}-${{ env.OSV_SCANNER_VERSION }}
- name: Install OSV-Scanner
if: steps.cache-osv-scanner.outputs.cache-hit != 'true'
run: |
curl -fsSL -o "$HOME/.local/bin/osv-scanner" \
"https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}/osv-scanner_linux_amd64"
chmod +x "$HOME/.local/bin/osv-scanner"
- name: Run OSV-Scanner
run: |
set +e
osv-scanner scan source --recursive --format json --output-file osv-results.json .
echo "OSV_EXIT=$?" >> "$GITHUB_ENV"
- name: Ergebnisse & Gitea-Issue erstellen/aktualisieren
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
TOKEN: ${{ secrets.SECURITY_TOKEN }}
RUN_URL: ${{ gitea.server_url || github.server_url }}/${{ gitea.repository || github.repository }}/actions/runs/${{ gitea.run_id || github.run_id }}
run: |
python3 scripts/report-security-issue.py trivy-results.json osv-results.json
+111
View File
@@ -0,0 +1,111 @@
name: Auto-PR (Testing → Main)
on:
push:
branches:
- testing
jobs:
create-pr:
name: Erstelle automatisch PR von testing nach main
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Prüfe auf bereits offenen PR nach main
id: check_pr
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
run: |
OPEN_PRS=$(curl -s -H "Authorization: token ${TOKEN}" "${GITEA_URL}/api/v1/repos/${REPO}/pulls?state=open&limit=50")
EXISTS=$(echo "$OPEN_PRS" | jq -r '[.[] | select(.base.ref == "main" and .head.ref == "testing")] | length')
echo "Bereits offene testing→main PRs: ${EXISTS}"
echo "exists=${EXISTS}" >> "$GITHUB_OUTPUT"
- name: Ermittle Versionen auf main & testing
id: versions
if: steps.check_pr.outputs.exists == '0'
run: |
git fetch origin main
MAIN_VERSION="$(git show origin/main:Cargo.toml | sed -n 's/^version = "\(.*\)"/\1/p' | head -n1)"
TESTING_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
echo "Version auf main: ${MAIN_VERSION} / Version auf testing: ${TESTING_VERSION}"
echo "main_version=${MAIN_VERSION}" >> "$GITHUB_OUTPUT"
echo "testing_version=${TESTING_VERSION}" >> "$GITHUB_OUTPUT"
- name: Ermittle geänderte Kategorien (main...testing)
id: categories
if: steps.check_pr.outputs.exists == '0' && steps.versions.outputs.main_version == steps.versions.outputs.testing_version
run: |
CHANGED_FILES="$(git diff --name-only origin/main...HEAD)"
echo "Geänderte Dateien main...testing:"
echo "$CHANGED_FILES"
WORKFLOWS="false"
CONFIG="false"
DOCS="false"
if echo "$CHANGED_FILES" | grep -q '^\.gitea/workflows/'; then
WORKFLOWS="true"
fi
if echo "$CHANGED_FILES" | grep -qE '^(renovate\.json|qodana\.yaml|Cargo\.toml|Cargo\.lock|\.cargo/)'; then
CONFIG="true"
fi
if echo "$CHANGED_FILES" | grep -qE '(^|/)[^/]+\.md$|^LICENSE$'; then
DOCS="true"
fi
echo "workflows=${WORKFLOWS}" >> "$GITHUB_OUTPUT"
echo "config=${CONFIG}" >> "$GITHUB_OUTPUT"
echo "docs=${DOCS}" >> "$GITHUB_OUTPUT"
- name: Bestimme PR-Titel
id: title
if: steps.check_pr.outputs.exists == '0'
run: |
if [ "${{ steps.versions.outputs.main_version }}" != "${{ steps.versions.outputs.testing_version }}" ]; then
TITLE="Merge testing in main: Release ${{ steps.versions.outputs.testing_version }}"
else
PARTS=()
[ "${{ steps.categories.outputs.workflows }}" = "true" ] && PARTS+=("Workflows")
[ "${{ steps.categories.outputs.config }}" = "true" ] && PARTS+=("Konfigurationen")
[ "${{ steps.categories.outputs.docs }}" = "true" ] && PARTS+=("Dokumentation")
if [ ${#PARTS[@]} -eq 0 ]; then
TITLE="Merge testing in main"
else
JOINED=""
for PART in "${PARTS[@]}"; do
if [ -z "$JOINED" ]; then
JOINED="$PART"
else
JOINED="${JOINED} & ${PART}"
fi
done
TITLE="Merge testing in main: ${JOINED} aktualisiert"
fi
fi
echo "Ermittelter PR-Titel: ${TITLE}"
echo "title=${TITLE}" >> "$GITHUB_OUTPUT"
- name: Erstelle PR (testing -> main)
if: steps.check_pr.outputs.exists == '0'
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
TITLE: ${{ steps.title.outputs.title }}
run: |
PAYLOAD=$(jq -n --arg title "$TITLE" --arg head "testing" --arg base "main" \
'{title: $title, head: $head, base: $base}')
curl -f -s -S -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/pulls"
echo "PR erstellt: ${TITLE}"
+249
View File
@@ -0,0 +1,249 @@
name: Testing Build, Publish & Preview Release
on:
push:
branches:
- testing
jobs:
detect-changes:
name: Erkenne relevante Code-Änderungen
runs-on: ubuntu-latest
outputs:
code_changed: ${{ steps.filter.outputs.code }}
steps:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Prüfe auf Änderungen am Programmcode
uses: dorny/paths-filter@v4
id: filter
with:
filters: |
code:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'scripts/**'
- '.cargo/**'
- '.gitea/workflows/testing.yaml'
build-and-publish:
name: Build, Publish Packages (Testing) & Create Preview Release
needs: detect-changes
if: needs.detect-changes.outputs.code_changed == 'true'
runs-on: ubuntu-latest
env:
CARGO_BINSTALL_VERSION: "1.23.0"
CARGO_DEB_VERSION: "3.8.0"
CARGO_GENERATE_RPM_VERSION: "0.21.0"
steps:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Install Rust Toolchain
uses: actions-rust-lang/setup-rust-toolchain@v2
with:
toolchain: stable
cache: false
- name: Cache Cargo-Abhängigkeiten & Build-Artefakte
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-
- name: Alte Paketierungs-Ausgaben aus dem Cache entfernen
run: rm -rf target/debian target/generate-rpm target/arch
- name: Install Cross-Compilation Toolchains (apt)
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu
- name: Ermittle Rust-Version für Rustup-Target-Cache-Key
run: echo "RUST_VERSION=$(rustc --version | awk '{print $2}')" >> "$GITHUB_ENV"
- name: Cache Rustup Cross-Compilation-Targets
id: cache-rustup-targets
uses: actions/cache@v6
with:
path: |
~/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/aarch64-unknown-linux-gnu
key: rustup-targets-${{ runner.os }}-${{ env.RUST_VERSION }}
- name: Add Rust Cross-Compilation Targets
if: steps.cache-rustup-targets.outputs.cache-hit != 'true'
run: rustup target add aarch64-unknown-linux-gnu
- name: PATH um Cargo-bin-Verzeichnis ergänzen
run: |
mkdir -p ~/.cargo/bin
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: Cache Packaging-Tools (cargo-binstall, cargo-deb, cargo-generate-rpm)
id: cache-packaging-tools
uses: actions/cache@v6
with:
path: |
~/.cargo/bin/cargo-binstall
~/.cargo/bin/cargo-deb
~/.cargo/bin/cargo-generate-rpm
key: packaging-tools-${{ runner.os }}-${{ env.CARGO_BINSTALL_VERSION }}-${{ env.CARGO_DEB_VERSION }}-${{ env.CARGO_GENERATE_RPM_VERSION }}
- name: Install Packaging Tools (Prebuilt Binaries)
if: steps.cache-packaging-tools.outputs.cache-hit != 'true'
run: |
curl -fsSL "https://github.com/cargo-bins/cargo-binstall/releases/download/v${CARGO_BINSTALL_VERSION}/cargo-binstall-x86_64-unknown-linux-musl.tgz" | tar -xz -C ~/.cargo/bin
~/.cargo/bin/cargo-binstall -y --no-symlinks "cargo-deb@${CARGO_DEB_VERSION}" "cargo-generate-rpm@${CARGO_GENERATE_RPM_VERSION}"
- name: Run Tests
run: |
cargo test
- name: Build Release Binaries
run: |
cargo build --release --target x86_64-unknown-linux-gnu
cargo build --release --target aarch64-unknown-linux-gnu
- name: Determine Build Number
id: build_num
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
run: |
BUILD_NUM=$(python3 scripts/get-build-number.py)
echo "build_number=${BUILD_NUM}" >> $GITHUB_OUTPUT
echo "BUILD_NUMBER=${BUILD_NUM}" >> $GITHUB_ENV
echo "Ermittelte Build-Nummer: ${BUILD_NUM}"
- name: Build Debian Packages (.deb)
run: |
cargo deb --target x86_64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
cargo deb --target aarch64-unknown-linux-gnu --deb-revision "${BUILD_NUMBER}" --no-build
- name: Build Fedora / RPM Packages (.rpm)
run: |
mkdir -p target/generate-rpm
cargo generate-rpm --target x86_64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
cargo generate-rpm --target aarch64-unknown-linux-gnu -s "release=\"${BUILD_NUMBER}\"" -o target/generate-rpm
- name: Build Arch Linux Packages (.pkg.tar.zst)
run: |
python3 scripts/package-arch.py --target x86_64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
python3 scripts/package-arch.py --target aarch64-unknown-linux-gnu --pkgrel "${BUILD_NUMBER}"
- name: Publish Packages to Gitea Package Registry
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO_OWNER: ${{ gitea.repository_owner || github.repository_owner }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
run: |
echo "Veröffentliche Debian-Paket (Distribution: testing, Component: main)..."
for deb in target/debian/*.deb; do
[ -f "$deb" ] || continue
curl -f -s -S -X PUT \
-H "Authorization: token ${TOKEN}" \
--upload-file "$deb" \
"${GITEA_URL}/api/packages/${REPO_OWNER}/debian/pool/testing/main/upload"
done
echo "Veröffentliche Fedora/RPM-Paket (Gruppe: testing)..."
for rpm in target/generate-rpm/*.rpm; do
[ -f "$rpm" ] || continue
curl -f -s -S -X PUT \
-H "Authorization: token ${TOKEN}" \
--upload-file "$rpm" \
"${GITEA_URL}/api/packages/${REPO_OWNER}/rpm/testing/upload"
done
echo "Veröffentliche Arch Linux-Paket (Repository: testing)..."
for pkg in target/arch/*.pkg.tar.zst; do
[ -f "$pkg" ] || continue
curl -f -s -S -X PUT \
-H "Authorization: token ${TOKEN}" \
--upload-file "$pkg" \
"${GITEA_URL}/api/packages/${REPO_OWNER}/arch/testing"
done
- name: Create Gitea Pre-Release and Upload Assets
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
REPO_NAME: ${{ gitea.repository_name || github.event.repository.name }}
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
run: |
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
TAG_NAME="v${VERSION}-preview"
RELEASE_TITLE="Preview Release ${TAG_NAME}"
RELEASE_NOTES="Automatisches Preview-Release für ${REPO_NAME} ${VERSION} (Branch: Testing)."
echo "Erstelle oder hole Preview-Release für Tag ${TAG_NAME} in ${REPO}..."
GET_RESP=$(curl -s -w "\n%{http_code}" \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG_NAME}")
HTTP_CODE=$(echo "$GET_RESP" | tail -n1)
BODY=$(echo "$GET_RESP" | sed '$d')
RELEASE_ID=""
if [ "$HTTP_CODE" -eq 200 ]; then
RELEASE_ID=$(echo "$BODY" | jq -r '.id // empty' 2>/dev/null || echo "$BODY" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
echo "Bestehendes Release gefunden (ID: ${RELEASE_ID})."
else
echo "Erstelle neues Preview-Release ${TAG_NAME}..."
CREATE_PAYLOAD=$(cat <<EOF
{
"tag_name": "${TAG_NAME}",
"target_commitish": "testing",
"name": "${RELEASE_TITLE}",
"body": "${RELEASE_NOTES}",
"draft": false,
"prerelease": true
}
EOF
)
CREATE_RESP=$(curl -f -s -S -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "$CREATE_PAYLOAD" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases")
RELEASE_ID=$(echo "$CREATE_RESP" | jq -r '.id // empty' 2>/dev/null || echo "$CREATE_RESP" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
echo "Neues Preview-Release erstellt (ID: ${RELEASE_ID})."
fi
if [ -z "$RELEASE_ID" ]; then
echo "Fehler: Release-ID konnte nicht ermittelt werden!"
exit 1
fi
EXISTING_ASSETS_JSON=$(curl -s \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" || echo "[]")
for file in target/debian/*.deb target/generate-rpm/*.rpm target/arch/*.pkg.tar.zst; do
[ -f "$file" ] || continue
filename="$(basename "$file")"
echo "Lade Release-Asset hoch: $filename"
ASSET_ID=$(echo "$EXISTING_ASSETS_JSON" | jq -r --arg name "$filename" '.[]? | select(.name == $name) | .id' 2>/dev/null | head -n1 || true)
if [ -n "$ASSET_ID" ] && [ "$ASSET_ID" != "null" ]; then
echo "Lösche altes Asset mit ID ${ASSET_ID}..."
curl -s -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${ASSET_ID}" || true
fi
curl -f -s -S -X POST \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
echo "Asset ${filename} erfolgreich hochgeladen."
done
+59
View File
@@ -0,0 +1,59 @@
name: TruffleHog Secret Scan
on:
push:
pull_request:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
jobs:
trufflehog-scan:
name: TruffleHog
runs-on: ubuntu-latest
env:
TRUFFLEHOG_VERSION: "3.97.4"
steps:
- name: Checkout Repository
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Lokales bin-Verzeichnis zum PATH hinzufügen
run: |
mkdir -p "$HOME/.local/bin"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Cache TruffleHog-Binary
id: cache-trufflehog
uses: actions/cache@v6
with:
path: ~/.local/bin/trufflehog
key: trufflehog-bin-${{ runner.os }}-${{ env.TRUFFLEHOG_VERSION }}
- name: Install TruffleHog
if: steps.cache-trufflehog.outputs.cache-hit != 'true'
run: |
curl -fsSL -o trufflehog.tar.gz \
"https://github.com/trufflesecurity/trufflehog/releases/download/v${TRUFFLEHOG_VERSION}/trufflehog_${TRUFFLEHOG_VERSION}_linux_amd64.tar.gz"
tar -xzf trufflehog.tar.gz trufflehog
chmod +x trufflehog
mv trufflehog "$HOME/.local/bin/trufflehog"
rm trufflehog.tar.gz
- name: Run TruffleHog Scanner
run: |
set +e
trufflehog git file://. --results=verified,unknown --fail --json > trufflehog-results.json
echo "TRUFFLEHOG_EXIT=$?" >> "$GITHUB_ENV"
- name: Ergebnisse & Gitea-Issue erstellen/aktualisieren
env:
GITEA_URL: ${{ gitea.server_url || github.server_url }}
REPO: ${{ gitea.repository || github.repository }}
TOKEN: ${{ secrets.SECURITY_TOKEN }}
RUN_URL: ${{ gitea.server_url || github.server_url }}/${{ gitea.repository || github.repository }}/actions/runs/${{ gitea.run_id || github.run_id }}
ISSUE_TITLE: "Security-Scan: TruffleHog Secrets"
ISSUE_LABEL: "security-scan-trufflehog"
run: |
python3 scripts/report-security-issue.py "" "" trufflehog-results.json
+38
View File
@@ -0,0 +1,38 @@
name: Unit-Tests
on:
pull_request:
types:
- opened
- synchronize
- reopened
branches:
- testing
jobs:
test:
name: Unit-Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v7
- name: Install Rust Toolchain
uses: actions-rust-lang/setup-rust-toolchain@v2
with:
toolchain: stable
cache: false
- name: Cache Cargo-Abhängigkeiten & Build-Artefakte
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: |
cargo-${{ runner.os }}-
- name: Run Tests
run: cargo test
+84
View File
@@ -0,0 +1,84 @@
name: Auto Patch-Version-Bump (Dev → Testing PR)
on:
pull_request:
types: [opened]
branches:
- testing
jobs:
detect-changes:
name: Erkenne relevante Änderungen im PR
runs-on: ubuntu-latest
if: ${{ (gitea.head_ref || github.head_ref) == 'dev' }}
outputs:
code_changed: ${{ steps.filter.outputs.code }}
steps:
- name: Checkout Dev-Branch (PR-Head)
uses: actions/checkout@v7
with:
ref: ${{ gitea.head_ref || github.head_ref }}
fetch-depth: 0
- name: Prüfe auf Änderungen an Cargo.toml, Cargo.lock oder src/
uses: dorny/paths-filter@v4
id: filter
with:
base: ${{ gitea.base_ref || github.base_ref }}
filters: |
code:
- 'Cargo.toml'
- 'Cargo.lock'
- 'src/**'
bump-version:
name: Patch-Version erhöhen & auf Dev pushen
needs: detect-changes
if: needs.detect-changes.outputs.code_changed == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout Dev-Branch (PR-Head)
uses: actions/checkout@v7
with:
ref: ${{ gitea.head_ref || github.head_ref }}
fetch-depth: 0
token: ${{ secrets.PACKAGE_TOKEN || secrets.RELEASE_TOKEN || secrets.PUBLISH_TOKEN || secrets.API_TOKEN || secrets.PAT_TOKEN || secrets.CUSTOM_TOKEN || secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN || github.token }}
- name: Ermittle Cargo-Version auf testing & dev
id: versions
run: |
git fetch origin testing --depth=1
TESTING_VERSION="$(git show origin/testing:Cargo.toml | sed -n 's/^version = "\(.*\)"/\1/p' | head -n1)"
DEV_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
echo "Version auf testing: ${TESTING_VERSION} / Version auf dev: ${DEV_VERSION}"
echo "testing_version=${TESTING_VERSION}" >> "$GITHUB_OUTPUT"
echo "dev_version=${DEV_VERSION}" >> "$GITHUB_OUTPUT"
- name: Patch-Version um 1 erhöhen (Cargo.toml & Cargo.lock)
if: steps.versions.outputs.testing_version == steps.versions.outputs.dev_version
run: |
VERSION="${{ steps.versions.outputs.dev_version }}"
MAJOR="$(echo "$VERSION" | cut -d. -f1)"
MINOR="$(echo "$VERSION" | cut -d. -f2)"
PATCH="$(echo "$VERSION" | cut -d. -f3)"
NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))"
echo "Erhöhe Version: ${VERSION} -> ${NEW_VERSION}"
sed -i "0,/^version = \"${VERSION}\"/s//version = \"${NEW_VERSION}\"/" Cargo.toml
PACKAGE_NAME="$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -n1)"
awk -v new="$NEW_VERSION" -v pkg="$PACKAGE_NAME" '
found_name && /^version = "/ {
print "version = \"" new "\""
found_name = 0
next
}
$0 == "name = \"" pkg "\"" { found_name = 1 }
{ print }
' Cargo.lock > Cargo.lock.tmp && mv Cargo.lock.tmp Cargo.lock
git config user.name "Gitea-Bot"
git config user.email "no-reply@creativedragonslayer.de"
git add Cargo.toml Cargo.lock
git commit -m "Chore: Erhöht Patch-Version auf ${NEW_VERSION} für Promotion nach testing"
git push origin HEAD:${{ gitea.head_ref || github.head_ref }}
+1 -401
View File
@@ -4,10 +4,6 @@
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
@@ -113,400 +109,4 @@ fabric.properties
# Built Visual Studio Code Extensions
*.vsix
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
*.code-workspace
# Local History for Visual Studio Code
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea
# Added by cargo
/target
.junie/plans
+1
View File
@@ -3,6 +3,7 @@
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JsonSchemaMappingsProjectConfiguration">
<state>
<map>
<entry key="Cargo Config">
<value>
<SchemaInfo>
<option name="name" value="Cargo Config" />
<option name="relativePathToSchema" value="https://www.schemastore.org/cargo-config.json" />
<option name="applicationDefined" value="true" />
<option name="patterns">
<list>
<Item>
<option name="path" value=".cargo/config.toml" />
</Item>
</list>
</option>
</SchemaInfo>
</value>
</entry>
<entry key="GitHub Workflow">
<value>
<SchemaInfo>
<option name="name" value="GitHub Workflow" />
<option name="relativePathToSchema" value="https://www.schemastore.org/github-workflow.json" />
<option name="applicationDefined" value="true" />
<option name="patterns">
<list>
<Item>
<option name="path" value=".gitea/workflows/code-quality.yaml" />
</Item>
<Item>
<option name="path" value=".gitea/workflows/version-bump.yaml" />
</Item>
<Item>
<option name="path" value=".gitea/workflows/unit-tests.yaml" />
</Item>
<Item>
<option name="path" value=".gitea/workflows/trufflehog-scan.yaml" />
</Item>
<Item>
<option name="path" value=".gitea/workflows/testing-to-main-pr.yaml" />
</Item>
<Item>
<option name="path" value=".gitea/workflows/testing.yaml" />
</Item>
<Item>
<option name="path" value=".gitea/workflows/security-scan.yaml" />
</Item>
<Item>
<option name="path" value=".gitea/workflows/renovate.yaml" />
</Item>
<Item>
<option name="path" value=".gitea/workflows/main.yaml" />
</Item>
</list>
</option>
</SchemaInfo>
</value>
</entry>
</map>
</state>
</component>
</project>
+138
View File
@@ -0,0 +1,138 @@
# AGENTS.md
Dieses Dokument dient als technischer Leitfaden und Kontextdokument für KI-Coding-Agenten (sowie Entwickler), die an diesem Repository oder daraus abgeleiteten Projekten arbeiten.
---
## 1. Projektübersicht & Philosophie
Dieses Repository ist ein **Rust-Projekt-Template** für Linux-Anwendungen und CLI-Tools mit Fokus auf:
- Automatisierte Multi-Architektur-Kompilierung (`x86_64`, `aarch64`).
- Native Paketierung für Debian (`.deb`), Fedora/RHEL (`.rpm`) und Arch Linux (`.pkg.tar.zst`) sowie Docker-Container-Images.
- Vollständig automatisierte CI/CD-Pipelines via Gitea Actions (kompatibel mit Forgejo / GitHub Actions).
- Automatisierte Sicherheits-Scans (Schwachstellen, Secrets) und Dependency-Updates.
### Standards & Tech-Stack
- **Sprache**: Rust (Edition 2024), Python 3 (für Hilfsskripte in `scripts/`).
- **Rust Toolchain**: Stable.
- **Zielplattform**: Linux (GLIBC-basiert, Cross-Kompilierung für `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`).
- **Container**: Docker-Images werden zusätzlich zu den nativen Paketen gebaut und in die Gitea Container Registry veröffentlicht.
- **Sicherheits-Tooling**: Trivy, OSV-Scanner, TruffleHog (Secret-Scanning), Renovate (Dependency-Updates), Qodana (statische Codeanalyse).
- **Lizenz**: GPL-3.0-or-later (sofern nicht im abgeleiteten Projekt anders definiert).
---
## 2. Projektstruktur
```text
├── .cargo/
│ └── config.toml # Linker für Cross-Target-Kompilierung & Registry-Konfiguration
├── .gitea/
│ └── workflows/
│ ├── main.yaml # CI/CD: Stabile Builds, Multi-Arch-Paketierung, Docker-Image, Release & Upload
│ ├── testing.yaml # CI/CD: Preview-Builds, Docker-Image & Testing-Pakete
│ ├── unit-tests.yaml # CI: Unit-Tests für Pull Requests gegen 'testing'
│ ├── security-scan.yaml # CI: Trivy & OSV-Scanner (Schwachstellen/Misconfig/Secrets)
│ ├── trufflehog-scan.yaml # CI: TruffleHog Secret-Scan (inkl. Git-Historie)
│ └── renovate.yaml # CI: Wöchentlicher Renovate-Lauf für Dependency-Updates
├── scripts/
│ ├── get-build-number.py # Ermittelt automatisch die nächste Revisions-/Build-Nummer
│ ├── package-arch.py # Erzeugt native Arch Linux .pkg.tar.zst Pakete
│ └── report-security-issue.py # Meldet Scan-Ergebnisse (Trivy/OSV/TruffleHog) als Gitea-Issue
├── src/
│ └── main.rs # Einstiegspunkt der Anwendung
├── Cargo.toml # Projekt-Manifest & Metadaten für deb, rpm und arch
├── qodana.yaml # Konfiguration für JetBrains Qodana (statische Analyse)
├── renovate.json # Renovate-Konfiguration (Gruppierung, Versions-Pins in Workflows)
├── LICENSE # Lizenztext
├── README.md # Benutzerdokumentation & Setup-Checkliste
└── AGENTS.md # Dieses Agenten-Handbuch
```
> **Hinweis:** `main.yaml`/`testing.yaml` bauen zusätzlich ein Docker-Image (`docker build .` mit `--build-arg TARGET_BIN=...`). Ein `Dockerfile` ist im Template noch **nicht** enthalten und muss von abgeleiteten Projekten ergänzt werden; der aktuell hartkodierte `TARGET_BIN`-Pfad (`.../release/mirror-package`) ist ein Platzhalter aus einem Referenzprojekt und muss beim Ableiten des Templates auf den tatsächlichen Binärnamen (`Cargo.toml` → `[package] name`) angepasst werden.
---
## 3. Regeln & Richtlinien für Agenten
### 3.1 Code-Stil & Best Practices
- **Idiomatisches Rust**: Nutze moderne Sprachfeatures der Rust Edition 2024.
- **Fehlerbehandlung**: Verwende aussagekräftige Fehlertypen (z. B. mit `thiserror` oder `anyhow` für CLIs). Vermeide unnötiges `unwrap()` oder `panic!()` im Produktivcode.
- **Kommentare**: Ergänze KDoc/RustDoc-Kommentare (`///`) an öffentlichen Funktionen und Typen. Behalte die bestehende Sprachkonvention bei.
- **Template-TODOs**: Wenn neue Vorlagen-Features oder Platzhalter ergänzt werden, markiere anpassungsbedürftige Stellen eindeutig mit `// TODO:` (Rust), `# TODO:` (TOML/Python/YAML).
### 3.2 Paketierungs-Metadaten in `Cargo.toml`
Bei Änderungen an Binärnamen, Abhängigkeiten oder Beschreibungen müssen die drei Metadaten-Blöcke in `Cargo.toml` synchron gehalten werden:
1. `[package.metadata.deb]` (für `cargo-deb`):
- `maintainer`, `copyright`, `section`, `priority`, `depends`, `extended-description`, `assets`.
2. `[package.metadata.generate-rpm]` (für `cargo-generate-rpm`):
- `requires`, `assets`.
3. `[package.metadata.arch]` (für `scripts/package-arch.py`):
- `pkgrel`, `arch`, `depends`, `optdepends`.
Ändert sich der Binärname (`[package] name`), muss auch der `TARGET_BIN`-Build-Arg im Docker-Build-Step von `main.yaml`/`testing.yaml` sowie das (abzuleitende) `Dockerfile` angepasst werden.
### 3.3 Skripte in `scripts/`
- **Generizität**: Die Skripte dürfen keine hardcodierten Anwendungsnamen, spezifischen Abhängigkeiten oder projektspezifischen URLs enthalten. Alle Werte müssen dynamisch aus `Cargo.toml` (via `cargo metadata` oder Dateiparsing) oder Umgebungsvariablen (`BUILD_NUMBER`, `GITEA_URL`, `REPO`, `TOKEN`) ermittelt werden.
- **Python-Kompatibilität**: Verwende Standard-Python 3 ohne externe PyPI-Abhängigkeiten (nur Standardbibliothek: `json`, `subprocess`, `urllib`, `argparse`, `os`, `re`, `tempfile`, `tarfile` etc.).
- **`get-build-number.py`**: Ermittelt die nächste Build-/Revisions-Nummer nicht mehr rein lokal, sondern dynamisch über:
1. Gitea Releases API (Tag-/Asset-Namen),
2. Gitea Packages API (jeweils neueste Version pro Paket-Typ: `debian`, `rpm`, `arch`),
3. lokale `target/{debian,generate-rpm,arch}`-Verzeichnisse als Fallback.
Unterstützt CLI-Flags (`--version`, `--gitea-url`, `--repo`, `--owner`, `--token`, `--build-number`) sowie Umgebungsvariablen-Fallbacks (`BUILD_NUMBER`/`BUILD_NUM`/`PKGREL`, `GITEA_URL`, `REPO`, `REPO_OWNER`, `TOKEN`). Package-Typen (deb/rpm/arch) teilen sich eine gemeinsame Build-Nummer.
- **`report-security-issue.py`**: Fasst Funde aus Trivy-, OSV-Scanner- und TruffleHog-JSON-Reports zusammen, sortiert nach Schweregrad und pflegt darüber ein einzelnes offenes Gitea-Issue pro Scan-Typ (Kommentar-Historie statt ständig neuer Issues; Label per `ISSUE_LABEL`, Titel per `ISSUE_TITLE` konfigurierbar). Secret-Werte selbst werden nie ausgegeben.
---
## 4. Workflows: Bauen, Testen & Validieren
Agenten müssen Änderungen vor dem Abschluss validieren.
### 4.1 Grundlegende Validierung
```bash
# Syntax- und Typprüfung
cargo check
# Unit- & Integrationstests
cargo test
# Release-Build prüfen
cargo build --release
```
### 4.2 Hilfsskripte testen
```bash
# Build-Nummern-Skript testen (rein lokal, ohne Gitea-API)
python3 scripts/get-build-number.py
# Arch Linux Paketierung lokal testen (nach 'cargo build --release')
python3 scripts/package-arch.py --arch x86_64 --pkgrel 1
```
---
## 5. CI/CD-Pipeline Details
Alle Workflows liegen unter `.gitea/workflows/` und nutzen gecachte Abhängigkeiten (`actions/cache@v6` für Cargo-Registry/Build-Artefakte, Rustup-Targets und Packaging-Tools) sowie fest gepinnte Tool-Versionen über `env`-Variablen — diese werden von Renovate automatisch aktuell gehalten (siehe 5.2).
### 5.1 Build & Release
- **`main.yaml`** (Trigger: `push` auf `main`): Baut Binaries für alle 3 Architekturen, führt `cargo test` aus, baut `.deb`, `.rpm` und `.pkg.tar.zst`, lädt sie in die Gitea Package Registry hoch, erstellt ein Gitea Release `v<VERSION>` und baut/veröffentlicht zusätzlich ein Docker-Image (`:latest`, `:<VERSION>`, `:v<VERSION>`, `:<VERSION>.<BUILD_NUMBER>`).
- **`testing.yaml`** (Trigger: `push` auf `testing`): Analog zu `main.yaml`, veröffentlicht jedoch in die `testing`-Kanäle der Paket-Registry, erstellt ein Pre-Release `v<VERSION>-preview` und taggt Docker-Images mit `:testing`, `:<VERSION>-preview`, `:<VERSION>-testing` etc.
- **`unit-tests.yaml`** (Trigger: `pull_request``testing`, bei `opened`/`synchronize`/`reopened`): Schnelle CI-Prüfung (`cargo test`) für Pull Requests, ohne Paketierung oder Veröffentlichung.
### 5.2 Sicherheit & Qualität
- **`security-scan.yaml`** (Trigger: `push` auf `main`/`testing`/`dev`, `pull_request`, wöchentlich montags 05:00 UTC, `workflow_dispatch`): Führt Trivy (`vuln`, `secret`, `misconfig`; Schweregrad `CRITICAL`/`HIGH`) und OSV-Scanner aus und meldet Funde über `scripts/report-security-issue.py` als Gitea-Issue (Label `security-scan`).
- **`trufflehog-scan.yaml`** (Trigger: `push`, `pull_request`, wöchentlich montags 06:00 UTC, `workflow_dispatch`): Durchsucht die volle Git-Historie (`fetch-depth: 0`) nach verifizierten/unbekannten Secrets und meldet Funde in ein eigenes Issue (Label `security-scan-trufflehog`) inkl. Rotations-Anleitung.
- **`qodana.yaml`**: Konfiguration für JetBrains Qodana (statische Codeanalyse, Profil `qodana.starter`); wird über eine externe/IDE-seitige Qodana-CI-Integration ausgeführt, nicht über einen eigenen Gitea-Workflow in diesem Template.
- **`renovate.yaml`** (Trigger: wöchentlich montags 04:00 UTC, `workflow_dispatch`): Führt Renovate (Container-Image, gepinnte Version) gegen die Gitea-Plattform aus und erstellt Pull Requests für veraltete Abhängigkeiten gemäß `renovate.json`.
### 5.3 Renovate-Konfiguration (`renovate.json`)
- Basis-Branch für PRs: `dev`.
- Gruppiert Updates nach `Gitea Actions`, `Cargo Dependencies` und `Docker-Images`.
- Custom-Regex-Manager halten die in den Workflows gepinnten Tool-Versionen aktuell: `TRIVY_VERSION`, `OSV_SCANNER_VERSION`, `TRUFFLEHOG_VERSION`, `CARGO_BINSTALL_VERSION`, `CARGO_DEB_VERSION`, `CARGO_GENERATE_RPM_VERSION`.
- Zeitplan: vor 6 Uhr montags (Europe/Berlin).
### 5.4 Secrets
- `PACKAGE_TOKEN` (Fallback-Kette: `RELEASE_TOKEN`, `PUBLISH_TOKEN`, `API_TOKEN`, `PAT_TOKEN`, `CUSTOM_TOKEN`, `GITEA_TOKEN`, `GITHUB_TOKEN`) — API-Zugriff auf Gitea Packages, Releases und die Container Registry.
- `SECURITY_TOKEN` — wird von `security-scan.yaml` und `trufflehog-scan.yaml` für das Erstellen/Kommentieren von Gitea-Issues verwendet.
- `RENOVATE_TOKEN` — Zugriffstoken für den Renovate-Lauf (Gitea-Plattform-Endpoint & Cargo-Registry-Host-Rule).
Generated
+3081
View File
File diff suppressed because it is too large Load Diff
+96 -14
View File
@@ -1,25 +1,107 @@
[package]
name = "SmartMount"
version = "0.2.0"
name = "smart-mount"
version = "2.0.0"
edition = "2024"
authors = ['DragonSlayer_14']
readme = "README.md"
license-file = "LICENSE"
repository = "https://gitea.creative-dragonslayer.de/creative-dragonslayer/SmartMount"
description = "SmartMount ist ein innovatives Tool zur intelligenten Verwaltung von Netzwerk-Dateisystemen. Es ermöglicht das automatische Einbinden von Netzwerk-Freigaben über das lokale Netzwerk und wechselt nahtlos zu einer Cloud-basierten Lösung, falls keine lokale Verbindung verfügbar ist. Durch diese hybride Architektur wird ein zuverlässiger Zugriff auf wichtige Daten sichergestellt - egal ob zu Hause oder unterwegs."
license = "GPL-3.0-or-later"
repository = "https://gitea.creative-dragonslayer.de/Linuxapps/SmartMount"
description = "Bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und schaltet automatisch zwischen LAN und Cloud um"
[dependencies]
time = { version="0.3.41", features = ["formatting", "macros", "local-offset"] }
serde = { version="1.0.219", features = ["derive"] }
confy = "1.0.0"
libc = "1.0.0-alpha.1"
config-ctdra = { version = "1.0.6", registry = "gitea" }
logger-ctdra = { version = "1.0.5", registry = "gitea" }
sudo-ctdra = { version = "1.0.1", registry = "gitea" }
clap = { version = "4", features = ["derive", "env"] }
clap_complete = "4"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "fs", "time", "sync"] }
# WORKAROUND für RUSTSEC-2026-0253 (Use-after-free in lru < 0.18.2), siehe mac2ip Cargo.toml:
# das Default-Feature "fts" zieht tantivy -> lru "^0.16.3" (verwundbar) ein, obwohl
# smart-mount keine Volltextsuche nutzt. "fts" bleibt deaktiviert, bis eine stabile
# turso-Version tantivy>=eine lru>=0.18.2 zulassende Version pinnt.
turso = { version = "0.7", default-features = false, features = ["mimalloc"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
anyhow = "1"
aes-gcm = "0.11.1"
dialoguer = "0.12.0"
uuid = { version = "1", features = ["v4"] }
# Eigene, direkte Zufallsquelle für Nonce-/Schlüssel-Generierung, entkoppelt von aes-gcms
# rand_core-Re-Export-Kette (siehe crypto/mod.rs, crypto/key.rs).
getrandom = "0.4"
[dev-dependencies]
tempfile = "3"
toml = "1.1.6"
[profile.release]
debug = "none"
[package.metadata.deb]
section = "utils"
priority = "optional"
# --- Paketierungs-Metadaten für Linux-Distributionen ---
provides = ["smartmount"]
depends = ["nmap", "$auto"]
[package.metadata.deb]
name = "smart-mount"
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"
recommends = "davfs2, cifs-utils, nfs-common"
extended-description = """\
smart-mount bindet Paare aus einem lokalen (LAN, WebDAV/SMB/NFS) und einem Cloud-Laufwerk
ein: ist das lokale Laufwerk erreichbar, wird es gemountet, sonst automatisch das
Cloud-Laufwerk. Ein Watchdog prüft periodisch die Erreichbarkeit und schaltet bei Bedarf
zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen Turso-Datenbank
gespeichert; smart-mount richtet sich beim Installieren automatisch als System-systemd-Dienst
ein und mountet konfigurierte Laufwerkspaare für die jeweils zugeordneten lokalen Nutzer.\
"""
maintainer-scripts = "packaging/deb"
assets = [
["target/release/smart-mount", "usr/bin/smart-mount", "755"],
["README.md", "usr/share/doc/smart-mount/README.md", "644"],
["LICENSE", "usr/share/doc/smart-mount/copyright", "644"],
["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"],
]
[package.metadata.generate-rpm]
assets = [
{ source = "target/release/smart-mount", dest = "/usr/bin/smart-mount", mode = "755" },
{ source = "README.md", dest = "/usr/share/doc/smart-mount/README.md", mode = "644", doc = true },
{ source = "LICENSE", dest = "/usr/share/licenses/smart-mount/LICENSE", mode = "644", license = true },
{ 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" },
]
requires = { "mac2ip" = "*", "nmap" = "*" }
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.
post_install_script = """
if command -v systemctl >/dev/null 2>&1; then
systemctl daemon-reload || true
systemctl enable --now smart-mount-mount.service smart-mount-watch.timer || true
fi
"""
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
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"]
optdepends = [
"davfs2: WebDAV-Laufwerke einbinden",
"cifs-utils: SMB/CIFS-Laufwerke einbinden",
"nfs-utils: NFS-Laufwerke einbinden",
]
+2 -2
View File
@@ -209,7 +209,7 @@ If you develop a new program, and you want it to be of the greatest possible use
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
SmartMount
Copyright (C) 2025 DragonSlayer_14
Copyright (C) 2026 creative-dragonslayer.de
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
@@ -221,7 +221,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
SmartMount Copyright (C) 2025 DragonSlayer_14
SmartMount Copyright (C) 2026 creative-dragonslayer.de
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
+316 -8
View File
@@ -1,14 +1,322 @@
# SmartMount
SmartMount ist ein innovatives Tool zur intelligenten Verwaltung von Netzwerk-Dateisystemen. Es ermöglicht das
automatische Einbinden von Netzwerk-Freigaben über das lokale Netzwerk und wechselt nahtlos zu einer Cloud-basierten
Lösung, falls keine lokale Verbindung verfügbar ist. Durch diese hybride Architektur wird ein zuverlässiger Zugriff auf
wichtige Daten sichergestellt - egal ob zu Hause oder unterwegs.
SmartMount bindet Paare aus einem **lokalen** (LAN, WebDAV/SMB/NFS) und einem **Cloud**-Laufwerk
ein: ist das lokale Laufwerk erreichbar, wird es gemountet - sonst automatisch das
Cloud-Laufwerk. Ein Watchdog prüft periodisch die Erreichbarkeit und schaltet bei Bedarf
zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen [Turso](https://github.com/tursodatabase/turso)-Datenbank
gespeichert, niemals im Klartext in der Konfigurationsdatei. Das Command heißt `smart-mount`.
### Build:
Die Synchronisation der Inhalte zwischen lokalem und Cloud-Laufwerk ist **nicht** Teil dieses
Programms - SmartMount setzt voraus, dass beide Seiten bereits inhaltlich synchron gehalten
werden (z. B. über ein separates Sync-Tool).
Für Debian muss `cargo-deb` installiert sein, dann kann man das Paket mit diesem Paket builden:
---
```shell
cargo deb --separate-debug-symbols --compress-debug-symbols
## 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).
- **`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.
- **Pro genutztem Mount-Typ** (nur was tatsächlich konfiguriert ist, wird zur Laufzeit
geprüft - siehe `MountBackend::check_available`):
- `davfs2` für WebDAV-Laufwerke
- `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.
---
## CLI-Nutzung
smart-mount läuft ausschließlich als root/System-Dienst - es gibt nur eine einzige,
systemweite Konfiguration. `drive add`/`edit`/`remove` sowie `mount`/`unmount`/`watch`
verlangen daher explizit Root-Rechte (`sudo`), auch für ein Laufwerkspaar, das nur für einen
bestimmten Nutzer gedacht ist (siehe `owner_user` unten). Rein lesende Befehle (`drive list`,
`status`, `doctor`) brauchen kein `sudo`.
```bash
# Neues Laufwerkspaar interaktiv anlegen (fragt Name, Mount-Typ, Adresse, Freigabe und ggf.
# Zugangsdaten ab)
sudo smart-mount drive add
# Nicht-interaktiv per Flags (für Skripte/Automatisierung) - fehlende Pflichtfelder sind
# dann ein Fehler statt eines (ohne Terminal ohnehin unmöglichen) Prompts. Jedes
# *-password-stdin-Flag liest genau eine Zeile von stdin (bei mehreren im selben Aufruf
# entsprechend mehrere Zeilen, eine pro Flag in Reihenfolge):
sudo smart-mount drive add --non-interactive \
--name NAS \
--local-kind smb --local-ip 192.168.1.50 --local-share share --local-username nasuser --local-password-stdin \
--cloud-kind webdav --cloud-host https://cloud.example.com/dav --cloud-share / --cloud-username clouduser --cloud-password cloud-pw-direkt \
<<< "lokales-passwort"
# (--local-password/--cloud-password gehen auch direkt als Argument, sind aber im
# Prozess-Listing/Shell-Verlauf sichtbar - für Skripte lieber *-stdin verwenden)
# Bestehendes Laufwerkspaar bearbeiten - nur angegebene Felder ändern sich, alles andere
# (inkl. gespeichertem Passwort) bleibt unangetastet. Ohne Flags: interaktiver Durchlauf,
# vorbelegt mit den aktuellen Werten (inkl. Rückfrage, ob das Passwort geändert werden soll).
sudo smart-mount drive edit <id> --name "Neuer Name" --local-ip 192.168.1.99
sudo smart-mount drive edit <id> --non-interactive --local-password-stdin <<< "neues-passwort"
# Konfigurierte Laufwerkspaare auflisten / entfernen (--json für Skripte)
smart-mount drive list
smart-mount drive list --json
sudo smart-mount drive remove <id>
# Ein einzelnes Paar oder alle einbinden/aushängen
sudo smart-mount mount --name <id>
sudo smart-mount mount --all
sudo smart-mount unmount --all
# Aktuellen Status (Mount-Zustand + Erreichbarkeit beider Seiten) anzeigen (--json für Skripte)
smart-mount status
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"
```
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.
### Wo die Laufwerke eingebunden werden
Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir`,
standardmäßig `<mount_base_dir>/<Name des Paares>` (z. B. `/run/media/NAS`) - mehrere Paare
stören sich also nie gegenseitig, und der Ordnername ist beim Durchsuchen sofort erkennbar
(statt einer kryptischen ID). Standardwert für `mount_base_dir`: `/run/media` - auf den
meisten Systemen bereits die übliche Konvention für eingebundene Wechseldatenträger/
Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie persistieren. Der
Name wird dabei zu einem sicheren Verzeichnisnamen bereinigt (nur alphanumerische Zeichen
sowie `-`/`_`, alles andere wird zu `-`); kollidiert das Ergebnis mit einem bereits
bestehenden Paar (Namen müssen nicht eindeutig sein), wird `-2`, `-3`, ... angehängt.
**Der Mountpoint lässt sich beim Anlegen frei überschreiben** - per `--mount-point <pfad>`
(nicht-interaktiv) oder im interaktiven Prompt, komplett unabhängig von `mount_base_dir`:
```bash
sudo smart-mount drive add --mount-point /mnt/nas --non-interactive ...
```
Bei `drive edit` bleibt der Mountpoint standardmäßig unverändert (kein Prompt dafür) - sonst
würde ein aktiver Mount verwaisen. Nur eine explizite `--mount-point`-Angabe ändert ihn (mit
einer Warnung, falls das Paar gerade aktiv gemountet ist - dann erst `unmount`, editieren,
dann erneut `mount`).
Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden bei jedem `mount`/
`watch`-Lauf automatisch angelegt, falls sie fehlen - der gemeinsame Elternordner
(`mount_base_dir`, bzw. bei einem komplett eigenen `--mount-point` dessen Elternordner) fest
mit `0755` (durchquerbar für jeden lokalen Nutzer, unabhängig vom Umask des root-Prozesses),
das jeweilige Backing-Verzeichnis (siehe unten) bei gesetztem `owner_user` zusätzlich fest
`chown`t und `0700` (nur der Owner). Ist für ein Paar `owner_user` gesetzt, bekommt genau dieser
Nutzer bei CIFS/WebDAV zusätzlich über die Mount-Optionen (`uid=`/`gid=`, siehe
"Architekturentscheidungen" unten) vollen Zugriff auf den *Inhalt* - die Trennung passiert also
über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer.
Der Standard für `mount_base_dir` lässt sich in `config.toml` unter
`[settings] mount_base_dir = "..."` jederzeit auf einen beliebigen anderen Pfad ändern.
---
## Automatischer Start beim Systemstart
Das .deb/.rpm/.pkg.tar.zst-Paket richtet smart-mount beim Installieren **automatisch** als
System-systemd-Dienst ein - kein manueller Schritt nötig. Die dafür paketierten Unit-Dateien
(`packaging/systemd/` im Quellbaum, installiert nach `/usr/lib/systemd/system/`) bestehen aus:
- `smart-mount-mount.service`: ein `oneshot`-Service, der beim Boot `smart-mount mount --all`
aufruft (nach `network-online.target`).
- `smart-mount-watch.service` + `smart-mount-watch.timer`: ein Timer, der periodisch
`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
`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.
**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:
```bash
sudo systemctl edit smart-mount-watch.timer
# [Timer]
# OnUnitActiveSec=60s
```
### 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:
```cron
@reboot smart-mount mount --all
*/2 * * * * smart-mount watch
```
---
## Architekturentscheidungen
- **Verhalten bei nicht mehr erreichbarem Server (der ganze Sinn dieses Tools)**: Netzwerk-
Dateisysteme können unter Linux "einfrieren", wenn der Server verschwindet - `umount`
oder Schreibzugriffe hängen dann scheinbar unbegrenzt. Das wird gezielt adressiert:
- **NFS**: Standardmäßig setzt `mount.nfs` `hard` (unbegrenzte Wiederholungsversuche bei
Zeitüberschreitung) - genau das Einfrierverhalten. SmartMount setzt automatisch `soft`,
sofern nicht bereits `hard`/`soft`/`softerr` explizit in `extra_options` gesetzt ist
(`man 5 nfs`, Abschnitt "soft / softerr / hard" nennt einen dauerhaft nicht erreichbaren
Server explizit als Zielszenario für `soft`).
- **CIFS**: `mount.cifs` verwendet laut eigener Dokumentation bereits standardmäßig `soft` -
kein Eingriff nötig.
- **WebDAV (davfs2)**: `umount` ist laut davfs2-eigener FAQ *absichtlich* so lange
blockierend, bis alle zwischengespeicherten Daten geschrieben sind - bei einem dauerhaft
unerreichbaren Server kehrt das nie zurück. Dafür gibt es keine passende Mount-Option.
`umount -l` (lazy) würde zwar sofort zurückkehren, macht laut `man umount` aber
**künftige Remounts derselben Freigabe bis zum nächsten Neustart unmöglich** - für ein
Tool, dessen ganzer Zweck automatisches Zurückschalten ist, wäre das schlimmer als das
ursprüngliche Problem.
- **Der eigentliche Fix**: jeder `mount`/`umount`-Aufruf läuft über das coreutils-Tool
`timeout` (30s). Bei `umount` wird ein Abbruch wegen Zeitüberschreitung toleriert (nur
geloggt, `Ok`) statt als Fehler behandelt - der nächste `watch`-Durchlauf versucht es
erneut (idempotent). Dadurch kann `smart-mount watch`/`unmount` selbst dann nicht mehr
unbegrenzt hängen bleiben, wenn eine einzelne Seite dauerhaft nicht antwortet - und weil
beim Umschalten zuerst die neue Seite gemountet und der Symlink umgebogen wird, bevor die
alte Seite (mit eben diesem tolerierten Timeout) ausgehängt wird, blockiert ein
hängender Alt-Mount den sichtbaren Wechsel ohnehin nicht.
- **Privilegienmodell**: SmartMount läuft ausschließlich als root/System-Dienst - es gibt
keinen unprivilegierten Mount-Weg mehr (früher: `/etc/fstab`-Einträge mit `user,noauto`, über
die ein einzelner Nutzer unprivilegiert selbst mounten konnte). Root mountet direkt
(`mount -t <typ> <quelle> <ziel> -o <optionen>`) für jedes konfigurierte Paar, unabhängig
davon, ob/für welchen Nutzer `owner_user` gesetzt ist - siehe `owner_user` unten für die
Zugriffssteuerung. Das vereinfacht das Rechtemodell erheblich und schließt nebenbei die
frühere strukturelle CIFS-Lücke (jeder unprivilegiert mount-berechtigte Nutzer konnte zuvor
jedes CIFS-Nutzer-Kontext-Paar mounten, nicht nur sein eigenes) - Zugangsdaten sind jetzt nur
noch root zugänglich.
- **Voller Zugriff für einen bestimmten Nutzer (`owner_user`)**: Bei CIFS/WebDAV (keine
nativen Unix-Rechte) setzt SmartMount automatisch `uid=`/`gid=`/`file_mode=0700`/
`dir_mode=0700`, sobald ein Paar ein `owner_user` hat - optional, ohne `owner_user` gehört
der Mount root. `smart-mount drive add` setzt `owner_user` standardmäßig auf den Nutzer, der
`sudo` aufgerufen hat (`$SUDO_USER`) - wer ein Paar einrichtet, bekommt also automatisch
vollen Zugriff darauf, sofern nicht per `--owner-user` (nicht-interaktiv) oder im
interaktiven Prompt bewusst ein anderer Nutzer angegeben bzw. abgelehnt wird. Bei NFS gibt es
keine clientseitige `uid=`/`gid=`-Option, Zugriff bestimmt dort ausschließlich der Server über
die tatsächlichen Datei-Eigentümer/-Rechte des Exports - `owner_user` wirkt sich hier nur auf
das lokale Backing-Verzeichnis selbst aus (`chown`+`0700`, s. o.), nicht auf das, was der
Server tatsächlich exportiert.
- **davfs2-Konfiguration**: SmartMount setzt in `davfs2.conf` automatisch `gui_optimize 1`
(bündelt PROPFIND-Anfragen, wichtig für grafische Dateimanager) sowie `buf_size 16384`
(deutlich über dem Standard von 16 KiB) - Letzteres behebt ein bekanntes Praxisproblem, bei
dem `ls` in Verzeichnissen mit vielen Dateien einen leeren/unvollständigen Inhalt zeigt,
obwohl einzelne Dateien direkt geöffnet werden können (der FUSE-readdir-Puffer wird bei zu
kleinem `buf_size` stillschweigend abgeschnitten).
- **Zugangsdaten-Verschlüsselung**: Turso hat aktuell keine produktionsreife eingebaute
Verschlüsselung, daher verschlüsselt SmartMount Passwörter selbst (AES-256-GCM) vor der
Ablage. Der Master-Schlüssel liegt in einer Schlüsseldatei (`chmod 600`, neben der
Konfiguration, also `/etc/smart-mount/master.key`) - kein OS-Keyring, da SmartMount
ausschließlich als root/System-Dienst läuft, für den es kein Nutzer-Keyring gibt.
- **`mac2ip`-Integration**: SmartMount ruft `mac2ip --json --auto-trust-networks <mac>` auf.
`--auto-trust-networks` lässt mac2ip die eigene "nmap-Scan in diesem Netzwerk erlauben?"-
Rückfrage automatisch bejahen und dauerhaft in seiner eigenen Cache-Datenbank merken - das
erspart SmartMount, das Konfigurationsschema eines fremden Tools zu kennen oder dort direkt
hineinzuschreiben.
---
## Struktur des Repositories
```text
├── .cargo/
│ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry)
├── .gitea/
│ └── workflows/ # CI/CD-Pipelines (Build, Tests, Security-Scans, Releases)
├── packaging/
│ ├── systemd/ # Statische systemd-Unit-Dateien (paketiert nach
│ │ # /usr/lib/systemd/system/)
│ └── deb/ # postinst/postrm für das .deb-Paket
├── scripts/
│ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer
│ ├── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen
│ └── report-security-issue.py # Security-Scan-Ergebnisse als Gitea-Issue melden
├── src/
│ ├── main.rs # Dünner Einstiegspunkt (CLI-Parsing, Dispatch)
│ ├── lib.rs # Bibliotheks-Wurzel
│ ├── cli/ # `clap`-Subcommands
│ ├── config/ # Konfigurationsschema + CRUD (config-ctdra)
│ ├── crypto/ # Verschlüsselung + Master-Key-Auflösung
│ ├── db/ # Verschlüsselte Zugangsdaten (Turso)
│ ├── mount/ # WebDAV/SMB/NFS-Backends, dynamischer Dispatch
│ ├── network/ # Erreichbarkeit, mac2ip-Integration
│ ├── reconcile/ # Watchdog-Entscheidungslogik (lokal/Cloud-Umschaltung)
│ └── systemd/ # Cron-Fallback (Systeme ohne systemd)
├── tests/ # Cross-Modul-Integrationstests
├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch)
├── LICENSE # Lizenzdatei (GPL-3.0-or-later)
├── AGENTS.md # Richtlinien und Leitfaden für KI-Coding-Agenten
└── README.md # Diese Datei
```
---
## Lokale Entwicklung
```bash
cargo build
cargo test
cargo build --release
# Debian-Paket bauen (.deb)
cargo deb
# RPM-Paket bauen (.rpm)
cargo generate-rpm
# Arch Linux-Paket bauen (.pkg.tar.zst)
python3 scripts/package-arch.py --arch x86_64 --pkgrel 1
```
---
## CI/CD Workflow-Übersicht
| Branch | Workflow | Paket-Kanal | Release-Typ |
| :--- | :--- | :--- | :--- |
| `main` | `.gitea/workflows/main.yaml` | `stable` | Offizielles Release (`v<VERSION>`) |
| `testing` | `.gitea/workflows/testing.yaml` | `testing` | Pre-Release (`v<VERSION>-preview`) |
Zusätzlich: automatisierte Security-Scans (Trivy, OSV-Scanner, TruffleHog), Renovate für
Abhängigkeits-Updates, sowie automatisiertes `cargo fmt`/`cargo clippy --fix` auf dem
`dev`-Branch.
---
## Lizenz
[GPL-3.0-or-later](LICENSE).
+13
View File
@@ -0,0 +1,13 @@
#!/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.
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
fi
exit 0
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Entfernt den beim Installieren eingerichteten systemd-Dienst 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).
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
fi
exit 0
@@ -0,0 +1,11 @@
[Unit]
Description=smart-mount: mount configured drive pairs at boot
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/smart-mount mount --all
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,6 @@
[Unit]
Description=smart-mount: check reachability and switch local/cloud if needed
[Service]
Type=oneshot
ExecStart=/usr/bin/smart-mount watch
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=smart-mount: periodic reconciling
[Timer]
OnBootSec=1min
OnUnitActiveSec=120s
Persistent=true
Unit=smart-mount-watch.service
[Install]
WantedBy=timers.target
+50
View File
@@ -0,0 +1,50 @@
#-------------------------------------------------------------------------------#
# Qodana analysis is configured by qodana.yaml file #
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
#-------------------------------------------------------------------------------#
#################################################################################
# WARNING: Do not store sensitive information in this file, #
# as its contents will be included in the Qodana report. #
#################################################################################
version: "1.0"
#Specify inspection profile for code analysis
profile:
name: qodana.starter
#Enable inspections
#include:
# - name: <SomeEnabledInspectionId>
#Disable inspections
#exclude:
# - name: <SomeDisabledInspectionId>
# paths:
# - <path/where/not/run/inspection>
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
#bootstrap: sh ./prepare-qodana.sh
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
#plugins:
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
# Quality gate. Will fail the CI/CD pipeline if any condition is not met
# severityThresholds - configures maximum thresholds for different problem severities
# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code
# dependencyLicenses - fails the run on prohibited or unknown dependency licenses
# Code Coverage is available in Ultimate and Ultimate Plus plans
#failureConditions:
# severityThresholds:
# any: 15
# critical: 5
# testCoverageThresholds:
# fresh: 70
# total: 50
# dependencyLicenses:
# failOnProhibited: true
# failOnUnknown: false
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
linter: jetbrains/qodana-<linter>:2026.2
+101
View File
@@ -0,0 +1,101 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"timezone": "Europe/Berlin",
"schedule": ["before 6am on monday"],
"baseBranchPatterns": [
"dev"
],
"packageRules": [
{
"matchFileNames": [".gitea/workflows/**"],
"groupName": "Gitea Actions",
"separateMajorMinor": false,
"separateMinorPatch": false
},
{
"matchManagers": ["cargo"],
"groupName": "Cargo Dependencies",
"separateMajorMinor": false,
"separateMinorPatch": false
},
{
"matchManagers": ["dockerfile", "docker-compose"],
"groupName": "Docker-Images",
"separateMajorMinor": false,
"separateMinorPatch": false
}
],
"customManagers": [
{
"customType": "regex",
"managerFilePatterns": [
"/^\\.gitea/workflows/.+\\.ya?ml$/"
],
"matchStrings": [
"TRIVY_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "aquasecurity/trivy",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"managerFilePatterns": [
"/^\\.gitea/workflows/.+\\.ya?ml$/"
],
"matchStrings": [
"OSV_SCANNER_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "google/osv-scanner",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"managerFilePatterns": [
"/^\\.gitea/workflows/.+\\.ya?ml$/"
],
"matchStrings": [
"TRUFFLEHOG_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "trufflesecurity/trufflehog",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"managerFilePatterns": [
"/^\\.gitea/workflows/.+\\.ya?ml$/"
],
"matchStrings": [
"CARGO_BINSTALL_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "cargo-bins/cargo-binstall",
"datasourceTemplate": "github-releases",
"extractVersionTemplate": "^v(?<version>.*)$"
},
{
"customType": "regex",
"managerFilePatterns": [
"/^\\.gitea/workflows/.+\\.ya?ml$/"
],
"matchStrings": [
"CARGO_DEB_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "cargo-deb",
"datasourceTemplate": "crate"
},
{
"customType": "regex",
"managerFilePatterns": [
"/^\\.gitea/workflows/.+\\.ya?ml$/"
],
"matchStrings": [
"CARGO_GENERATE_RPM_VERSION:\\s*\"(?<currentValue>[^\"]+)\""
],
"depNameTemplate": "cargo-generate-rpm",
"datasourceTemplate": "crate"
}
]
}
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""
Ermittelt automatisch die nächste Build-Nummer (Revision / Release / pkgrel)
für eine gegebene Paket-Version (z. B. 1.0.2 -> wenn 1.0.2-1 existiert, wird 2 zurückgegeben).
"""
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
# Paket-Typen, unter denen dasselbe Release veroeffentlicht wird und die sich
# daher eine gemeinsame Build-Nummer teilen muessen.
PACKAGE_TYPES = ("debian", "rpm", "arch")
def get_cargo_pkg_info():
try:
metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]))
pkg = metadata["packages"][0]
name = pkg.get("name", "")
version = pkg.get("version", "0.0.0")
repository = pkg.get("repository", "")
return name, version, repository
except Exception:
# Fallback auf einfaches Parsen der Cargo.toml
name = ""
version = "0.0.0"
repository = ""
if os.path.exists("Cargo.toml"):
with open("Cargo.toml", "r") as f:
for line in f:
line = line.strip()
if line.startswith("name ="):
name = line.split("=", 1)[1].strip().strip('"\'')
elif line.startswith("version ="):
version = line.split("=", 1)[1].strip().strip('"\'')
elif line.startswith("repository ="):
repository = line.split("=", 1)[1].strip().strip('"\'')
return name, version, repository
def parse_repo_info(repo_url):
"""Extrahiert Server-URL, Owner und Repo-Name aus einer Repository-URL."""
if not repo_url:
return None, None, None
parsed = urllib.parse.urlparse(repo_url)
server_url = f"{parsed.scheme}://{parsed.netloc}"
path_parts = [p for p in parsed.path.strip("/").split("/") if p]
if len(path_parts) >= 2:
owner = path_parts[0]
repo_name = path_parts[1]
if repo_name.endswith(".git"):
repo_name = repo_name[:-4]
return server_url, f"{owner}/{repo_name}", owner
return server_url, None, None
def query_existing_build_numbers(name, version, gitea_url, repo, owner, token=None):
build_nums = set()
pattern = re.compile(rf'(?:{re.escape(name)}[_-]|^v?){re.escape(version)}-(\d+)')
headers = {"User-Agent": f"{name}-build-resolver" if name else "rust-build-resolver"}
if token:
headers["Authorization"] = f"token {token}"
# 1. Gitea Releases API
if gitea_url and repo:
try:
url = f"{gitea_url.rstrip('/')}/api/v1/repos/{repo}/releases?limit=50"
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=10) as resp:
releases = json.loads(resp.read().decode())
for rel in releases:
tag = rel.get("tag_name", "")
m = pattern.search(tag)
if m:
build_nums.add(int(m.group(1)))
for asset in rel.get("assets", []):
asset_name = asset.get("name", "")
m = pattern.search(asset_name)
if m:
build_nums.add(int(m.group(1)))
except Exception as e:
sys.stderr.write(f"[Hinweis] Konnte Gitea Releases nicht abfragen: {e}\n")
# 2. Gitea Packages API (falls Token vorhanden): pro Paket-Typ gezielt nur die
# neueste Version abfragen (ein Request, keine Pagination noetig). Das haelt
# die Abfrage schnell unabhaengig von der Historie und ist unbeeinflusst von
# Docker-Tags/anderen Paket-Typen, die unter demselben Owner haengen.
if gitea_url and owner and token and name:
for pkg_type in PACKAGE_TYPES:
try:
url = (
f"{gitea_url.rstrip('/')}/api/v1/packages/{owner}/"
f"{pkg_type}/{urllib.parse.quote(name, safe='')}/-/latest"
)
req = urllib.request.Request(url, headers=headers)
with urllib.request.urlopen(req, timeout=10) as resp:
pkg = json.loads(resp.read().decode())
pkg_ver = pkg.get("version", "")
m = pattern.search(pkg_ver)
if m:
build_nums.add(int(m.group(1)))
except urllib.error.HTTPError as e:
if e.code != 404:
sys.stderr.write(f"[Hinweis] Konnte neueste {pkg_type}-Paketversion nicht abfragen: {e}\n")
except Exception as e:
sys.stderr.write(f"[Hinweis] Konnte neueste {pkg_type}-Paketversion nicht abfragen: {e}\n")
# 3. Lokales Target-Verzeichnis prüfen
target_dirs = ["target/debian", "target/generate-rpm", "target/arch"]
for t_dir in target_dirs:
if os.path.isdir(t_dir):
for file in os.listdir(t_dir):
m = pattern.search(file)
if m:
build_nums.add(int(m.group(1)))
return build_nums
def get_next_build_number(version=None, gitea_url=None, repo=None, owner=None, token=None, explicit_build_num=None):
if explicit_build_num is not None:
return int(explicit_build_num)
env_build_num = os.environ.get("BUILD_NUMBER") or os.environ.get("BUILD_NUM") or os.environ.get("PKGREL")
if env_build_num:
try:
return int(env_build_num)
except ValueError:
pass
pkg_name, pkg_version, pkg_repo = get_cargo_pkg_info()
if not version:
version = pkg_version
default_server, default_repo, default_owner = parse_repo_info(pkg_repo)
gitea_url = gitea_url or os.environ.get("GITEA_URL") or os.environ.get("GITHUB_SERVER_URL") or default_server
repo = repo or os.environ.get("REPO") or os.environ.get("GITHUB_REPOSITORY") or default_repo
owner = owner or os.environ.get("REPO_OWNER") or os.environ.get("GITHUB_REPOSITORY_OWNER") or default_owner
token = token or os.environ.get("TOKEN") or os.environ.get("GITEA_TOKEN") or os.environ.get("PACKAGE_TOKEN") or os.environ.get("RELEASE_TOKEN")
existing_nums = query_existing_build_numbers(pkg_name, version, gitea_url, repo, owner, token)
if existing_nums:
return max(existing_nums) + 1
return 1
def main():
parser = argparse.ArgumentParser(description="Ermittelt die nächste Build-Nummer für die Paketierung.")
parser.add_argument("--version", help="Paket-Version (Standard: aus Cargo.toml)")
parser.add_argument("--gitea-url", help="Gitea Basis-URL")
parser.add_argument("--repo", help="Repository (z. B. Owner/Repo)")
parser.add_argument("--owner", help="Repository Owner / Organisation")
parser.add_argument("--token", help="API-Token")
parser.add_argument("--build-number", type=int, help="Explizite Build-Nummer erzwingen")
args = parser.parse_args()
build_num = get_next_build_number(
version=args.version,
gitea_url=args.gitea_url,
repo=args.repo,
owner=args.owner,
token=args.token,
explicit_build_num=args.build_number,
)
print(build_num)
if __name__ == "__main__":
main()
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
import argparse
import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import time
TARGET_ARCH_MAP = {
"x86_64-unknown-linux-gnu": "x86_64",
"x86_64-unknown-linux-musl": "x86_64",
"x86_64": "x86_64",
"amd64": "x86_64",
"aarch64-unknown-linux-gnu": "aarch64",
"aarch64-unknown-linux-musl": "aarch64",
"aarch64": "aarch64",
"arm64": "aarch64",
}
def resolve_pkgrel(version=None, default="1"):
# 1. Environment Variable
env_pkgrel = os.environ.get("BUILD_NUMBER") or os.environ.get("BUILD_NUM") or os.environ.get("PKGREL")
if env_pkgrel:
return str(env_pkgrel)
# 2. get-build-number.py falls vorhanden
script_dir = os.path.dirname(os.path.abspath(__file__))
getter_path = os.path.join(script_dir, "get-build-number.py")
if os.path.exists(getter_path):
try:
spec = importlib.util.spec_from_file_location("get_build_number", getter_path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return str(module.get_next_build_number(version=version))
except Exception as e:
sys.stderr.write(f"[Hinweis] Konnte get-build-number nicht ausführen: {e}\n")
return str(default)
def collect_systemd_units(systemd_src_dir="packaging/systemd"):
"""Findet die im Projekt paketierten systemd-Unit-Dateien (falls vorhanden) und
unterscheidet dabei generisch - ohne einen Anwendungsnamen zu kennen - zwischen direkt
aktivierbaren Units (die einen '[Install]'-Abschnitt haben, z. B. Timer oder ein beim Boot
laufender oneshot-Service) und rein abhängigen Units (z. B. ein nur von einem Timer
ausgelöster Service ohne eigenen '[Install]'-Abschnitt, siehe `man systemd.unit`)."""
if not os.path.isdir(systemd_src_dir):
return [], []
unit_files = sorted(
f for f in os.listdir(systemd_src_dir) if f.endswith((".service", ".timer", ".socket"))
)
installable = []
for f in unit_files:
with open(os.path.join(systemd_src_dir, f)) as fh:
if "[Install]" in fh.read():
installable.append(f)
return unit_files, installable
def build_install_scriptlet(installable, all_units):
"""Erzeugt den Inhalt einer Arch-'.INSTALL'-Datei (siehe `man PKGBUILD`, Abschnitt
'install'), die den paketierten systemd-Dienst beim Installieren aktiviert/startet und beim
Entfernen wieder deaktiviert/stoppt - rein generisch anhand der tatsächlich gefundenen
Unit-Dateien, ohne Anwendungsnamen hart zu codieren."""
installable_str = " ".join(installable)
all_units_str = " ".join(all_units)
return f"""post_install() {{
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable --now {installable_str} >/dev/null 2>&1 || true
}}
post_upgrade() {{
systemctl daemon-reload >/dev/null 2>&1 || true
}}
pre_remove() {{
systemctl disable --now {all_units_str} >/dev/null 2>&1 || true
}}
post_remove() {{
systemctl daemon-reload >/dev/null 2>&1 || true
}}
"""
def build_package(target_triple=None, target_arch=None, pkgrel=None):
metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]))
pkg = metadata["packages"][0]
name = pkg["name"]
version = pkg["version"]
description = pkg.get("description", "")
license_name = pkg.get("license", "")
repository = pkg.get("repository", "")
authors = pkg.get("authors", [])
author = authors[0] if authors else "Unknown"
arch_meta = pkg.get("metadata", {}).get("arch", {})
default_pkgrel = arch_meta.get("pkgrel", "1")
if not pkgrel:
pkgrel = resolve_pkgrel(version=version, default=default_pkgrel)
default_arch = arch_meta.get("arch", "x86_64")
if target_arch:
arch = TARGET_ARCH_MAP.get(target_arch, target_arch)
elif target_triple:
arch = TARGET_ARCH_MAP.get(target_triple, default_arch)
else:
arch = default_arch
candidate_paths = []
if target_triple:
candidate_paths.append(f"target/{target_triple}/release/{name}")
candidate_paths.append(f"target/release/{name}")
bin_path = None
for p in candidate_paths:
if os.path.exists(p):
bin_path = p
break
if not bin_path:
raise FileNotFoundError(
f"Keine kompilierte Binary für {name} gefunden. Gesuchte Pfade: {candidate_paths}"
)
depends = arch_meta.get("depends", ["gcc-libs", "glibc"])
optdepends = arch_meta.get("optdepends", [])
with tempfile.TemporaryDirectory() as build_dir:
bin_dir = os.path.join(build_dir, "usr/bin")
doc_dir = os.path.join(build_dir, f"usr/share/doc/{name}")
lic_dir = os.path.join(build_dir, f"usr/share/licenses/{name}")
os.makedirs(bin_dir, exist_ok=True)
os.makedirs(doc_dir, exist_ok=True)
os.makedirs(lic_dir, exist_ok=True)
subprocess.run(["install", "-m", "755", bin_path, f"{bin_dir}/{name}"], check=True)
if os.path.exists("LICENSE"):
subprocess.run(["install", "-m", "644", "LICENSE", f"{lic_dir}/LICENSE"], check=True)
if os.path.exists("README.md"):
subprocess.run(["install", "-m", "644", "README.md", f"{doc_dir}/README.md"], check=True)
systemd_src_dir = "packaging/systemd"
all_units, installable_units = collect_systemd_units(systemd_src_dir)
if all_units:
unit_dir = os.path.join(build_dir, "usr/lib/systemd/system")
os.makedirs(unit_dir, exist_ok=True)
for unit in all_units:
subprocess.run(
["install", "-m", "644", os.path.join(systemd_src_dir, unit), os.path.join(unit_dir, unit)],
check=True,
)
installed_size = subprocess.check_output(["du", "-sb", build_dir]).decode().split()[0]
builddate = str(int(time.time()))
pkginfo_lines = [
f"pkgname = {name}",
f"pkgbase = {name}",
f"pkgver = {version}-{pkgrel}",
f"pkgdesc = {description}",
f"url = {repository}",
f"builddate = {builddate}",
f"packager = {author}",
f"size = {installed_size}",
f"arch = {arch}",
f"license = {license_name}",
]
for dep in depends:
pkginfo_lines.append(f"depend = {dep}")
for optdep in optdepends:
pkginfo_lines.append(f"optdepend = {optdep}")
if all_units:
pkginfo_lines.append(f"install = {name}.install")
pkginfo_lines.append("makepkgopt = strip\n")
with open(os.path.join(build_dir, ".PKGINFO"), "w") as f:
f.write("\n".join(pkginfo_lines))
tar_members = [".PKGINFO", "usr"]
if all_units:
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))
tar_members.append(install_script_name)
os.makedirs("target/arch", exist_ok=True)
output_file = os.path.abspath(f"target/arch/{name}-{version}-{pkgrel}-{arch}.pkg.tar.zst")
subprocess.run(["tar", "--zstd", "-cf", output_file, *tar_members], cwd=build_dir, check=True)
print(f"Arch-Paket erfolgreich erstellt: {output_file}")
def main():
parser = argparse.ArgumentParser(description="Erstellt Arch Linux-Pakete (.pkg.tar.zst)")
parser.add_argument("--target", help="Rust Target-Triple (z.B. x86_64-unknown-linux-gnu, aarch64-unknown-linux-gnu)")
parser.add_argument("--arch", help="Architektur (z.B. x86_64, aarch64)")
parser.add_argument("--pkgrel", help="Release-/Build-Nummer (z.B. 1, 2, ...)")
args = parser.parse_args()
build_package(target_triple=args.target, target_arch=args.arch, pkgrel=args.pkgrel)
if __name__ == "__main__":
main()
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Erstellt oder kommentiert ein Gitea-Issue mit den Ergebnissen der Security-Scans.
Sucht ein offenes Issue mit dem Label ISSUE_LABEL (Standard: "security-scan").
Existiert eines, wird der aktuelle Scan-Stand als neuer Kommentar angehängt
(die Historie bleibt erhalten). Existiert keines (z.B. weil das letzte
geschlossen wurde), wird ein neues Issue erstellt. Gibt es keine Funde mehr,
wird ein offenes Issue nur kommentiert, nicht geschlossen.
Titel und Label lassen sich per Umgebungsvariable ISSUE_TITLE / ISSUE_LABEL
überschreiben, damit z.B. TruffleHog-Funde in ein eigenes Issue laufen statt
in das gemeinsame Trivy/OSV-Issue.
"""
import json
import os
import sys
import urllib.error
import urllib.request
LABEL_NAME = os.environ.get("ISSUE_LABEL", "security-scan")
LABEL_COLOR = "#b60205"
ISSUE_TITLE = os.environ.get("ISSUE_TITLE", "Security-Scan: Offene Schwachstellen")
SEVERITY_ORDER = {
"VERIFIED": -1,
"CRITICAL": 0,
"HIGH": 1,
"MEDIUM": 2,
"LOW": 3,
"UNKNOWN": 4,
"UNVERIFIED": 6,
}
def api(method, path, token, gitea_url, data=None):
url = f"{gitea_url}/api/v1{path}"
body = json.dumps(data).encode() if data is not None else None
req = urllib.request.Request(url, data=body, method=method)
req.add_header("Authorization", f"token {token}")
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=10) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
except urllib.error.HTTPError as e:
print(f"Gitea API Fehler ({method} {path}): {e.code} {e.read().decode()}", file=sys.stderr)
raise
def make_finding(source, id, severity, package, installed="-", fixed="-", target="-"):
return {
"source": source,
"id": id,
"severity": severity,
"package": package,
"installed": installed,
"fixed": fixed,
"target": target,
}
def cvss_score_to_severity(score):
try:
score = float(score)
except (TypeError, ValueError):
return "UNKNOWN"
if score >= 9.0:
return "CRITICAL"
if score >= 7.0:
return "HIGH"
if score >= 4.0:
return "MEDIUM"
if score > 0.0:
return "LOW"
return "UNKNOWN"
def load_trivy(path):
findings = []
if not path or not os.path.isfile(path):
return findings
with open(path) as f:
data = json.load(f)
for result in data.get("Results", []) or []:
target = result.get("Target", "?")
for vuln in result.get("Vulnerabilities", []) or []:
findings.append(make_finding(
"Trivy",
vuln.get("VulnerabilityID", "?"),
vuln.get("Severity", "UNKNOWN"),
vuln.get("PkgName", "?"),
installed=vuln.get("InstalledVersion", "?"),
fixed=vuln.get("FixedVersion") or "-",
target=target,
))
for misc in result.get("Misconfigurations", []) or []:
findings.append(make_finding(
"Trivy (Misconfig)",
misc.get("ID", "?"),
misc.get("Severity", "UNKNOWN"),
misc.get("Title", "?"),
target=target,
))
for secret in result.get("Secrets", []) or []:
findings.append(make_finding(
"Trivy (Secret)",
secret.get("RuleID", "?"),
secret.get("Severity", "UNKNOWN"),
secret.get("Title", "?"),
target=target,
))
return findings
def load_osv(path):
findings = []
if not path or not os.path.isfile(path):
return findings
with open(path) as f:
data = json.load(f)
for result in data.get("results", []) or []:
source = (result.get("source") or {}).get("path", "?")
for pkg in result.get("packages", []) or []:
info = pkg.get("package", {})
pkg_name = f"{info.get('name', '?')} ({info.get('ecosystem', '?')})"
severity_by_id = {}
for group in pkg.get("groups", []) or []:
label = cvss_score_to_severity(group.get("max_severity"))
for vuln_id in group.get("ids", []) or []:
severity_by_id[vuln_id] = label
for vuln in pkg.get("vulnerabilities", []) or []:
vuln_id = vuln.get("id", "?")
findings.append(make_finding(
"OSV-Scanner",
vuln_id,
severity_by_id.get(vuln_id, "UNKNOWN"),
pkg_name,
installed=info.get("version", "?"),
target=source,
))
return findings
def load_trufflehog(path):
findings = []
if not path or not os.path.isfile(path):
return findings
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
git_meta = ((entry.get("SourceMetadata") or {}).get("Data") or {}).get("Git") or {}
findings.append(make_finding(
"TruffleHog",
entry.get("DetectorName", "?"),
"VERIFIED" if entry.get("Verified") else "UNVERIFIED",
git_meta.get("file", "?"),
target=git_meta.get("commit", "-"),
))
return findings
def sort_findings(findings):
return sorted(findings, key=lambda f: (SEVERITY_ORDER.get(f["severity"], 9), f["id"]))
def print_summary(findings):
if not findings:
print("Keine Funde.")
return
widths = {
key: max(len(key), *(len(str(f[key])) for f in findings))
for key in ("source", "id", "severity", "package", "installed", "fixed", "target")
}
header = ("source", "id", "severity", "package", "installed", "fixed", "target")
row_fmt = " ".join(f"{{:{widths[k]}}}" for k in header)
print(row_fmt.format(*header))
print(row_fmt.format(*("-" * widths[k] for k in header)))
for f in findings:
print(row_fmt.format(*(str(f[k]) for k in header)))
TRUFFLEHOG_GUIDANCE = """### Vorgehen bei gefundenen Secrets
1. **Sofort rotieren/widerrufen**: Das betroffene Secret (Token, Passwort, Schlüssel) beim jeweiligen Dienst ungültig machen und durch ein neues ersetzen. Ein einmal committetes Secret gilt als kompromittiert, auch wenn es später aus der Historie entfernt wird.
2. **Ursache beheben**: Neues Secret nur noch über Umgebungsvariablen/Secrets-Store einbinden, nicht erneut hart codieren.
3. **Historie bereinigen (optional, manuell, erst nach Schritt 1)**: Mit `git filter-repo` oder BFG Repo-Cleaner den Commit-Inhalt entfernen, danach `git push --force` in Absprache mit allen Mitwirkenden bestehende Clones/Forks werden dadurch ungültig.
4. **Issue schließen**, sobald rotiert wurde. TruffleHog findet das alte Secret ggf. weiterhin in der Historie nach der Rotation ist das unkritisch.
> Der Wert des Secrets selbst wird hier bewusst nicht ausgegeben, auch nicht gekürzt nur Detector, Datei und Commit. Fund lässt sich über "Ziel" (Commit-Hash) und "Paket" (Dateipfad) lokalisieren."""
def escape_md_cell(value):
return str(value).replace("|", "\\|").replace("\r", " ").replace("\n", " ")
def build_report(findings, run_url):
lines = [
"Automatisch erstellt vom Security-Scan-Workflow.",
f"Lauf: {run_url}" if run_url else "",
"",
"| Quelle | ID | Schweregrad | Paket | Installiert | Fix | Ziel |",
"|---|---|---|---|---|---|---|",
]
for f in findings:
cells = (f["source"], f["id"], f["severity"], f["package"], f["installed"], f["fixed"], f["target"])
lines.append("| " + " | ".join(escape_md_cell(c) for c in cells) + " |")
if any(f["source"] == "TruffleHog" for f in findings):
lines.append("")
lines.append(TRUFFLEHOG_GUIDANCE)
return "\n".join(lines)
def ensure_label(token, gitea_url, repo):
page = 1
while True:
labels = api("GET", f"/repos/{repo}/labels?limit=50&page={page}", token, gitea_url) or []
for label in labels:
if label.get("name") == LABEL_NAME:
return label["id"]
if len(labels) < 50:
break
page += 1
created = api("POST", f"/repos/{repo}/labels", token, gitea_url, {
"name": LABEL_NAME,
"color": LABEL_COLOR,
"description": "Automatisch verwaltet vom Security-Scan-Workflow",
})
return created["id"]
def find_open_issue(token, gitea_url, repo):
issues = api(
"GET",
f"/repos/{repo}/issues?state=open&type=issues&labels={LABEL_NAME}",
token,
gitea_url,
) or []
for issue in issues:
if issue.get("title") == ISSUE_TITLE:
return issue
return None
def main():
trivy_path = sys.argv[1] if len(sys.argv) > 1 else None
osv_path = sys.argv[2] if len(sys.argv) > 2 else None
trufflehog_path = sys.argv[3] if len(sys.argv) > 3 else None
gitea_url = os.environ.get("GITEA_URL", "").strip().rstrip("/")
repo = os.environ.get("REPO", "").strip()
token = os.environ.get("TOKEN", "").strip()
run_url = os.environ.get("RUN_URL", "")
osv_exit = int(os.environ.get("OSV_EXIT", "0"))
trufflehog_exit = int(os.environ.get("TRUFFLEHOG_EXIT", "0"))
findings = sort_findings(load_trivy(trivy_path) + load_osv(osv_path) + load_trufflehog(trufflehog_path))
print_summary(findings)
if not token or not gitea_url or not repo:
missing = [name for name, val in [("TOKEN", token), ("GITEA_URL", gitea_url), ("REPO", repo)] if not val]
print(f"{', '.join(missing)} nicht gesetzt oder leer überspringe Gitea-Issue-Synchronisation.")
else:
open_issue = find_open_issue(token, gitea_url, repo)
if findings:
report = build_report(findings, run_url)
if open_issue:
print(f"Kommentiere bestehendes Issue #{open_issue['number']} mit {len(findings)} Fund(en).")
api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, {"body": report})
else:
label_id = ensure_label(token, gitea_url, repo)
print(f"Erstelle neues Issue mit {len(findings)} Fund(en).")
api("POST", f"/repos/{repo}/issues", token, gitea_url, {
"title": ISSUE_TITLE,
"body": report,
"labels": [label_id],
})
elif open_issue:
print(f"Keine aktuellen Funde mehr. Kommentiere Issue #{open_issue['number']}.")
api("POST", f"/repos/{repo}/issues/{open_issue['number']}/comments", token, gitea_url, {
"body": f"Aktueller Scan hat keine offenen Schwachstellen mehr gefunden.\n\n{run_url}".strip(),
})
else:
print("Keine Funde und kein offenes Issue vorhanden.")
osv_ok_exits = {0, 1}
trufflehog_ok_exits = {0, 183}
if osv_exit not in osv_ok_exits:
print(f"WARNUNG: osv-scanner beendete sich mit unerwartetem Exit-Code {osv_exit} - Scan evtl. unvollständig.", file=sys.stderr)
if trufflehog_exit not in trufflehog_ok_exits:
print(f"WARNUNG: trufflehog beendete sich mit unerwartetem Exit-Code {trufflehog_exit} - Scan evtl. unvollständig.", file=sys.stderr)
has_trivy_findings = any(f["source"].startswith("Trivy") for f in findings)
if (
has_trivy_findings
or osv_exit == 1
or trufflehog_exit == 183
or osv_exit not in osv_ok_exits
or trufflehog_exit not in trufflehog_ok_exits
):
sys.exit(1)
if __name__ == "__main__":
main()
+50
View File
@@ -0,0 +1,50 @@
//! `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};
pub async fn run(json: bool) -> anyhow::Result<()> {
let cfg = config::pairs::load()?;
let results = doctor::run_checks(&cfg);
if json {
#[derive(serde::Serialize)]
struct JsonResult<'a> {
label: &'a str,
status: &'static str,
detail: &'a str,
}
let json_results: Vec<JsonResult> = results
.iter()
.map(|r| JsonResult {
label: &r.label,
status: match r.status {
CheckStatus::Ok => "ok",
CheckStatus::Warn => "warn",
CheckStatus::Fail => "fail",
},
detail: &r.detail,
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_results)?);
} else {
for r in &results {
let symbol = match r.status {
CheckStatus::Ok => "[ok] ",
CheckStatus::Warn => "[warn]",
CheckStatus::Fail => "[FAIL]",
};
println!("{symbol} {}: {}", r.label, r.detail);
}
}
let failures = results
.iter()
.filter(|r| r.status == CheckStatus::Fail)
.count();
if failures > 0 {
anyhow::bail!("{failures} problem(s) found");
}
Ok(())
}
+737
View File
@@ -0,0 +1,737 @@
//! `smart-mount drive add|edit|remove|list`.
//!
//! `add`/`edit` unterstützen zwei Modi: interaktiv (Standard, `dialoguer`-Prompts, mit
//! bereits per Flag/`edit` vorhandenen Werten als Vorbelegung) und nicht-interaktiv
//! (`--non-interactive`, für Skripte/Automatisierung - fehlende Pflichtfelder sind dann ein
//! Fehler statt eines Prompts, der ohne TTY ohnehin fehlschlagen würde).
use std::net::Ipv4Addr;
use std::path::{Path, PathBuf};
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};
#[derive(Subcommand)]
pub enum DriveAction {
/// Create a new drive pair (interactive, unless all fields are given via flags).
Add(DriveArgs),
/// Edit an existing drive pair - only the given fields change.
Edit {
id: String,
#[command(flatten)]
args: DriveArgs,
},
/// Remove an existing drive pair.
Remove { id: String },
/// List all configured drive pairs.
List {
/// Output as JSON instead of a table - for scripts.
#[arg(long)]
json: bool,
},
}
#[derive(Args, Default)]
pub struct DriveArgs {
#[arg(long)]
name: Option<String>,
/// Where the pair's symlink/backing directories live. Default (on 'add', derived from the
/// name): '<mount_base_dir>/<name>' (see 'settings.mount_base_dir'). Ignored on 'edit'
/// unless explicitly given - an existing pair's mount point stays fixed otherwise, so an
/// active mount doesn't get orphaned.
#[arg(long)]
mount_point: Option<PathBuf>,
#[arg(long)]
owner_user: Option<String>,
#[arg(long, value_enum)]
local_kind: Option<MountKind>,
#[arg(long, conflicts_with = "local_mac")]
local_ip: Option<Ipv4Addr>,
#[arg(long, conflicts_with = "local_ip")]
local_mac: Option<String>,
#[arg(long)]
local_share: Option<String>,
#[arg(long)]
local_username: Option<String>,
/// Insecure (visible in process listing/shell history) - prefer
/// `--local-password-stdin` for scripts.
#[arg(long, conflicts_with = "local_password_stdin")]
local_password: Option<String>,
/// Reads the local password as one line from stdin instead of passing it as an argument.
#[arg(long)]
local_password_stdin: bool,
#[arg(long, value_enum)]
cloud_kind: Option<MountKind>,
#[arg(long)]
cloud_host: Option<String>,
#[arg(long)]
cloud_share: Option<String>,
#[arg(long)]
cloud_username: Option<String>,
/// Insecure (visible in process listing/shell history) - prefer
/// `--cloud-password-stdin` for scripts.
#[arg(long, conflicts_with = "cloud_password_stdin")]
cloud_password: Option<String>,
/// Reads the cloud password as one line from stdin instead of passing it as an argument.
#[arg(long)]
cloud_password_stdin: bool,
/// Does not prompt for anything interactively - missing required fields cause an error
/// instead of a prompt (which would fail anyway without a terminal). For scripts/automation.
#[arg(long)]
non_interactive: bool,
}
pub async fn run(action: DriveAction) -> anyhow::Result<()> {
match action {
DriveAction::Add(args) => add(args).await,
DriveAction::Edit { id, args } => edit(&id, args).await,
DriveAction::Remove { id } => remove(&id).await,
DriveAction::List { json } => list(json).await,
}
}
async fn list(json: bool) -> anyhow::Result<()> {
let cfg: AppConfig = config::pairs::load()?;
if json {
println!("{}", serde_json::to_string_pretty(&cfg.pairs)?);
return Ok(());
}
if cfg.pairs.is_empty() {
println!("No drive pairs configured.");
return Ok(());
}
for pair in &cfg.pairs {
println!(
"{} \"{}\" lokal={} cloud={} -> {}",
pair.id,
pair.name,
pair.local.kind.as_str(),
pair.cloud.kind.as_str(),
pair.mount_point.display()
);
}
Ok(())
}
async fn remove(id: &str) -> anyhow::Result<()> {
crate::cli::require_root("drive remove")?;
// 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.
let cfg = config::pairs::load()?;
let pair = config::pairs::find_pair(&cfg, id)?;
// Best-effort wie `cleanup_credentials` weiter unten: ein Unmount-Fehler (z. B. weil das
// Paar von einem anderen Nutzer/Kontext gemountet wurde und wir keine Berechtigung haben)
// 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 {
logger_ctdra::warn(
"drive",
&format!("could not unmount drive pair '{id}' before removal: {e}"),
);
}
config::pairs::remove_pair(id)?;
let creds = CredentialStore::open().await?;
creds.delete(id, None).await?;
smart_mount::mount::cleanup_credentials(&pair, &cfg.settings);
println!("Drive pair '{id}' removed.");
Ok(())
}
async fn add(args: DriveArgs) -> anyhow::Result<()> {
crate::cli::require_root("drive add")?;
let ni = args.non_interactive;
let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required");
// Der Nutzer, der das Paar einrichtet, bekommt standardmäßig vollen Zugriff darauf (siehe
// `invoking_user`) - überschreibbar per '--owner-user' oder (interaktiv) durch Ablehnen/
// einen anderen Namen im Prompt.
let owner_user = resolve_owner_user(args.owner_user, invoking_user().as_deref(), ni)?;
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_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?;
let (local_username, local_password) = resolve_credentials(
local_kind,
args.local_username,
local_password_flag,
None,
ni,
"Local credentials",
)?;
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_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?;
let (cloud_username, cloud_password) = resolve_credentials(
cloud_kind,
args.cloud_username,
cloud_password_flag,
None,
ni,
"Cloud credentials",
)?;
let id = uuid::Uuid::new_v4().to_string();
// Bewusst KEIN `unwrap_or_default()`: ein Ladefehler bedeutet eine kaputte/korrupte
// Konfigurationsdatei (eine fehlende Datei liefert bereits Standardwerte, siehe
// `config_ctdra::load`), nicht "noch keine Konfiguration vorhanden". Stillschweigend mit
// `GlobalSettings::default()` weiterzumachen würde nicht nur einen ggf. abweichenden
// `mount_base_dir` ignorieren, sondern - schwerwiegender - auch das nachfolgende
// `add_pair()` (das intern selbst frisch lädt) auf denselben kaputten Zustand treffen
// lassen, was dort ALLE bestehenden Paare durch die eine neue Konfiguration ersetzen
// würde. Besser früh mit einem klaren Fehler abbrechen.
let cfg = config::pairs::load()?;
let default_mount_point = default_mount_point(&cfg.settings.mount_base_dir, &name, &cfg.pairs);
let mount_point = resolve_mount_point(args.mount_point, &default_mount_point, ni)?;
let pair = DrivePair {
id: id.clone(),
name,
enabled: true,
owner_user,
mount_point,
local: LocalSide {
kind: local_kind,
address: local_address,
share: local_share,
username: local_username.clone(),
extra_options: vec![],
},
cloud: CloudSide {
kind: cloud_kind,
host_or_url: cloud_host,
share: cloud_share,
username: cloud_username.clone(),
extra_options: vec![],
},
};
config::pairs::add_pair(pair)?;
let creds = CredentialStore::open().await?;
if let Some(pw) = local_password {
creds
.put(&id, Side::Local, local_username.as_deref(), None, &pw)
.await?;
}
if let Some(pw) = cloud_password {
creds
.put(&id, Side::Cloud, cloud_username.as_deref(), None, &pw)
.await?;
}
println!("Drive pair '{id}' created.");
Ok(())
}
async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
crate::cli::require_root("drive edit")?;
let cfg = config::pairs::load()?;
let existing = config::pairs::find_pair(&cfg, id)?;
let ni = args.non_interactive;
let name = resolve_field(args.name, Some(&existing.name), "Drive pair name", ni, true)?
.expect("required");
let owner_user = resolve_owner_user(args.owner_user, existing.owner_user.as_deref(), ni)?;
let local_kind = resolve_kind(
args.local_kind,
Some(existing.local.kind),
"Local mount type",
ni,
)?;
let local_address = resolve_local_address(
args.local_ip,
args.local_mac,
Some(&existing.local.address),
ni,
)?;
let local_share = resolve_field(
args.local_share,
Some(&existing.local.share),
"Local share/export path",
ni,
true,
)?
.expect("required");
let creds = CredentialStore::open().await?;
let current_local_cred = creds.get(id, Side::Local).await?;
let local_password_flag = read_password_flag(args.local_password, args.local_password_stdin)?;
let (local_username, local_password) = resolve_credentials(
local_kind,
args.local_username,
local_password_flag,
current_local_cred.as_ref(),
ni,
"Local credentials",
)?;
let cloud_kind = resolve_kind(
args.cloud_kind,
Some(existing.cloud.kind),
"Cloud mount type",
ni,
)?;
let cloud_host = resolve_field(
args.cloud_host,
Some(&existing.cloud.host_or_url),
"Cloud server address/URL",
ni,
true,
)?
.expect("required");
let cloud_share = resolve_field(
args.cloud_share,
Some(&existing.cloud.share),
"Cloud share/export path",
ni,
true,
)?
.expect("required");
let current_cloud_cred = creds.get(id, Side::Cloud).await?;
let cloud_password_flag = read_password_flag(args.cloud_password, args.cloud_password_stdin)?;
let (cloud_username, cloud_password) = resolve_credentials(
cloud_kind,
args.cloud_username,
cloud_password_flag,
current_cloud_cred.as_ref(),
ni,
"Cloud credentials",
)?;
// Anders als bei allen anderen Feldern gibt es hier bewusst KEINEN interaktiven Prompt: der
// Mountpoint (und damit die Backing-Verzeichnisse) bleibt standardmäßig unverändert, sonst
// 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() {
println!(
"Warning: pair '{id}' appears to be actively mounted - the old backing \
directories will be orphaned. Run 'sudo smart-mount unmount --name {id}' \
first, then 'mount --name {id}' again after this edit."
);
}
p
}
None => existing.mount_point.clone(),
};
let updated = DrivePair {
id: id.to_string(),
name,
enabled: existing.enabled,
owner_user,
mount_point,
local: LocalSide {
kind: local_kind,
address: local_address,
share: local_share,
username: local_username.clone(),
extra_options: existing.local.extra_options.clone(),
},
cloud: CloudSide {
kind: cloud_kind,
host_or_url: cloud_host,
share: cloud_share,
username: cloud_username.clone(),
extra_options: existing.cloud.extra_options.clone(),
},
};
config::pairs::update_pair(updated)?;
if let Some(pw) = local_password {
creds
.put(id, Side::Local, local_username.as_deref(), None, &pw)
.await?;
}
if let Some(pw) = cloud_password {
creds
.put(id, Side::Cloud, cloud_username.as_deref(), None, &pw)
.await?;
}
println!("Drive pair '{id}' updated.");
Ok(())
}
/// Liest ein Passwort entweder von stdin (eine Zeile, `\r`/`\n` abgeschnitten) oder gibt das
/// per Flag übergebene zurück.
fn read_password_flag(flag: Option<String>, stdin: bool) -> anyhow::Result<Option<String>> {
if stdin {
let mut buf = String::new();
std::io::stdin().read_line(&mut buf)?;
Ok(Some(buf.trim_end_matches(['\n', '\r']).to_string()))
} else {
Ok(flag)
}
}
/// Löst ein einzelnes String-Feld auf: Flag > (nicht-interaktiv: `current`, sonst Fehler bei
/// Pflichtfeld) > interaktiver Prompt (vorbelegt mit `current`, falls vorhanden).
fn resolve_field(
flag: Option<String>,
current: Option<&str>,
label: &str,
non_interactive: bool,
required: bool,
) -> anyhow::Result<Option<String>> {
if let Some(v) = flag {
return Ok(Some(v));
}
if non_interactive {
if required && current.is_none() {
anyhow::bail!(
"Field '{label}' is missing - specify it via a flag in non-interactive mode."
);
}
return Ok(current.map(str::to_string));
}
let mut input = Input::<String>::new();
input = input.with_prompt(label);
if let Some(d) = current {
input = input.default(d.to_string());
}
Ok(Some(input.interact_text()?))
}
/// Wandelt `name` in einen sicheren Verzeichnisnamen um (für den Standard-Mountpoint, siehe
/// `default_mount_point`): nur ASCII-alphanumerische Zeichen sowie `-`/`_` bleiben erhalten,
/// jede Folge anderer Zeichen (Leerzeichen, `/`, Sonderzeichen, Unicode) wird zu einem einzelnen
/// `-`; führende/folgende `-` werden entfernt. Ein leeres Ergebnis (z. B. bei einem rein aus
/// Sonderzeichen bestehenden Namen) fällt auf `"pair"` zurück, damit der Mountpoint nie
/// leer/unbrauchbar wird.
fn sanitize_dir_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
out.push(c);
} else if !out.ends_with('-') {
out.push('-');
}
}
match out.trim_matches('-') {
"" => "pair".to_string(),
trimmed => trimmed.to_string(),
}
}
/// Standard-Mountpoint für ein neues Paar: `<base>/<sanitierter Name>` (siehe
/// `sanitize_dir_name`). Namen sind - anders als `id` - nicht zwingend eindeutig (siehe
/// `config::pairs::find_pair`); kollidiert der Standard mit dem Mountpoint eines bestehenden
/// Paares, wird `-2`, `-3`, ... angehängt, bis er eindeutig ist.
fn default_mount_point(base: &Path, name: &str, existing: &[DrivePair]) -> PathBuf {
let sanitized = sanitize_dir_name(name);
let mut candidate = base.join(&sanitized);
let mut suffix = 2;
while existing.iter().any(|p| p.mount_point == candidate) {
candidate = base.join(format!("{sanitized}-{suffix}"));
suffix += 1;
}
candidate
}
/// Löst den Mountpoint auf: Flag > (interaktiv: Prompt vorbelegt mit `default`, editierbar) >
/// (nicht-interaktiv: `default` ungefragt übernehmen).
fn resolve_mount_point(
flag: Option<PathBuf>,
default: &Path,
non_interactive: bool,
) -> anyhow::Result<PathBuf> {
if let Some(p) = flag {
return Ok(p);
}
if non_interactive {
return Ok(default.to_path_buf());
}
let input: String = Input::new()
.with_prompt("Mount point")
.default(default.display().to_string())
.interact_text()?;
Ok(PathBuf::from(input))
}
/// Der Nutzer, in dessen Namen `sudo` aufgerufen wurde (falls so aufgerufen) - Standardwert für
/// `owner_user` beim Anlegen eines neuen Paares (siehe `add`), damit der einrichtende Nutzer
/// ohne extra Angabe vollen Zugriff auf sein eigenes Laufwerkspaar bekommt. `None`, wenn direkt
/// als root angemeldet (kein "einrichtender Nutzer" außer root selbst) oder `SUDO_USER` leer/
/// nicht gesetzt ist.
fn invoking_user() -> Option<String> {
std::env::var("SUDO_USER").ok().filter(|u| !u.is_empty())
}
/// 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
/// 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.
fn resolve_owner_user(
flag: Option<String>,
default_owner: Option<&str>,
non_interactive: bool,
) -> anyhow::Result<Option<String>> {
if let Some(v) = flag {
return Ok(Some(v));
}
if non_interactive {
return Ok(default_owner.map(str::to_string));
}
let prompt = match default_owner {
Some(user) => format!(
"Should '{user}' get full access to this drive (uid/gid, including script execution)?"
),
None => "Should a specific user get full access to this drive (uid/gid, including script execution)?".to_string(),
};
let want_owner = Confirm::new()
.with_prompt(prompt)
.default(default_owner.is_some())
.interact()?;
if !want_owner {
return Ok(None);
}
let mut input = Input::<String>::new().with_prompt("Linux username");
if let Some(c) = default_owner {
input = input.default(c.to_string());
}
Ok(Some(input.interact_text()?))
}
fn resolve_kind(
flag: Option<MountKind>,
current: Option<MountKind>,
label: &str,
non_interactive: bool,
) -> anyhow::Result<MountKind> {
if let Some(k) = flag {
return Ok(k);
}
if let Some(k) = current
&& non_interactive
{
return Ok(k);
}
if non_interactive {
anyhow::bail!(
"Field '{label}' is missing - specify it via a flag in non-interactive mode."
);
}
let default_idx = match current {
Some(MountKind::Smb) => 1,
Some(MountKind::Nfs) => 2,
_ => 0,
};
let idx = Select::new()
.with_prompt(label)
.items(["WebDAV", "SMB/CIFS", "NFS"])
.default(default_idx)
.interact()?;
Ok(match idx {
0 => MountKind::WebDav,
1 => MountKind::Smb,
_ => MountKind::Nfs,
})
}
fn resolve_local_address(
ip_flag: Option<Ipv4Addr>,
mac_flag: Option<String>,
current: Option<&LocalAddress>,
non_interactive: bool,
) -> anyhow::Result<LocalAddress> {
if let Some(ip) = ip_flag {
return Ok(LocalAddress::Ip(ip));
}
if let Some(mac) = mac_flag {
return Ok(LocalAddress::Mac(mac));
}
if non_interactive {
return current.cloned().ok_or_else(|| {
anyhow::anyhow!("Local address is missing - specify '--local-ip' or '--local-mac'.")
});
}
let default_idx = usize::from(matches!(current, Some(LocalAddress::Mac(_))));
let idx = Select::new()
.with_prompt("Local addressing")
.items(["IP address", "MAC address (resolved via mac2ip)"])
.default(default_idx)
.interact()?;
if idx == 0 {
let mut input = Input::<String>::new().with_prompt("IP address");
if let Some(LocalAddress::Ip(ip)) = current {
input = input.default(ip.to_string());
}
let ip_str: String = input.interact_text()?;
Ok(LocalAddress::Ip(Ipv4Addr::from_str(&ip_str)?))
} else {
let mut input = Input::<String>::new().with_prompt("MAC address (e.g. aa:bb:cc:dd:ee:ff)");
if let Some(LocalAddress::Mac(mac)) = current {
input = input.default(mac.clone());
}
Ok(LocalAddress::Mac(input.interact_text()?))
}
}
/// Löst Nutzername/Passwort für eine Seite auf.
///
/// - Explizit per Flag/stdin gegebenes Passwort wird immer übernommen.
/// - Nicht-interaktiv ohne neues Passwort: nur der Nutzername wird ggf. aktualisiert, das
/// gespeicherte Passwort bleibt unangetastet (`None` im Rückgabewert = "nicht ändern").
/// - Interaktiv beim Bearbeiten (`current.is_some()`): fragt separat, ob das Passwort
/// überhaupt geändert werden soll (Standard: nein) - ein Edit erzwingt keine Neueingabe.
/// - Interaktiv beim Anlegen: fragt Nutzername+Passwort zusammen ab, sofern der Mount-Typ
/// Zugangsdaten braucht (WebDAV/SMB immer, NFS nur auf Wunsch für Kerberos).
fn resolve_credentials(
kind: MountKind,
username_flag: Option<String>,
password_flag: Option<String>,
current: Option<&Credential>,
non_interactive: bool,
label: &str,
) -> anyhow::Result<(Option<String>, Option<String>)> {
let current_username = current.and_then(|c| c.username.clone());
if let Some(pw) = password_flag {
let username = username_flag.or(current_username);
return Ok((username, Some(pw)));
}
if non_interactive {
return Ok((username_flag.or(current_username), None));
}
let is_edit = current.is_some();
let needs = match kind {
MountKind::WebDav | MountKind::Smb => true,
MountKind::Nfs => Confirm::new()
.with_prompt(format!("{label}: Kerberos credentials for NFS?"))
.default(is_edit)
.interact()?,
};
if !needs {
return Ok((username_flag.or(current_username), None));
}
let mut username_input = Input::<String>::new().with_prompt(format!("{label}: username"));
if let Some(u) = username_flag.or(current_username) {
username_input = username_input.default(u);
}
let username: String = username_input.interact_text()?;
if is_edit {
let change_password = Confirm::new()
.with_prompt(format!("{label}: change password?"))
.default(false)
.interact()?;
if !change_password {
return Ok((Some(username), None));
}
}
let password: String = Password::new()
.with_prompt(format!("{label}: password"))
.with_confirmation("Confirm password", "Passwords do not match")
.interact()?;
Ok((Some(username), Some(password)))
}
#[cfg(test)]
mod tests {
use super::*;
use smart_mount::config::LocalAddress;
use std::net::Ipv4Addr;
fn sample_pair(mount_point: &str) -> DrivePair {
DrivePair {
id: "pair-1".into(),
name: "Test".into(),
enabled: true,
owner_user: None,
mount_point: PathBuf::from(mount_point),
local: LocalSide {
kind: MountKind::Nfs,
address: LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)),
share: "share".into(),
username: None,
extra_options: vec![],
},
cloud: CloudSide {
kind: MountKind::Nfs,
host_or_url: "cloud.example.com".into(),
share: "share".into(),
username: None,
extra_options: vec![],
},
}
}
#[test]
fn sanitize_dir_name_keeps_simple_names_unchanged() {
assert_eq!(sanitize_dir_name("NAS"), "NAS");
assert_eq!(sanitize_dir_name("my-nas_2"), "my-nas_2");
}
#[test]
fn sanitize_dir_name_collapses_runs_of_special_characters_to_one_dash() {
assert_eq!(sanitize_dir_name("My NAS / Drive"), "My-NAS-Drive");
}
#[test]
fn sanitize_dir_name_trims_leading_and_trailing_dashes() {
assert_eq!(sanitize_dir_name(" NAS!!"), "NAS");
assert_eq!(sanitize_dir_name("/etc/passwd"), "etc-passwd");
}
#[test]
fn sanitize_dir_name_falls_back_to_pair_for_only_special_characters() {
assert_eq!(sanitize_dir_name("!!!"), "pair");
assert_eq!(sanitize_dir_name(""), "pair");
}
#[test]
fn default_mount_point_uses_base_dir_and_sanitized_name() {
let point = default_mount_point(Path::new("/run/media"), "NAS", &[]);
assert_eq!(point, PathBuf::from("/run/media/NAS"));
}
#[test]
fn default_mount_point_dedupes_against_existing_pairs() {
let existing = vec![
sample_pair("/run/media/NAS"),
sample_pair("/run/media/NAS-2"),
];
let point = default_mount_point(Path::new("/run/media"), "NAS", &existing);
assert_eq!(point, PathBuf::from("/run/media/NAS-3"));
}
#[test]
fn default_mount_point_is_unaffected_by_unrelated_existing_pairs() {
let existing = vec![sample_pair("/run/media/OtherPair")];
let point = default_mount_point(Path::new("/run/media"), "NAS", &existing);
assert_eq!(point, PathBuf::from("/run/media/NAS"));
}
}
+106
View File
@@ -0,0 +1,106 @@
//! `clap`-CLI-Definition und Dispatch.
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;
/// 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.
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).");
}
Ok(())
}
#[derive(Parser)]
#[command(
name = "smart-mount",
version,
about = "Dynamically mounts local/cloud drive pairs and switches between them automatically"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Manage drive pairs (add/edit/remove/list).
Drive {
#[command(subcommand)]
action: Box<drive::DriveAction>,
},
/// Mount configured drive pairs.
Mount {
/// Only mount this pair (ID or name).
#[arg(long)]
name: Option<String>,
/// Mount all configured pairs.
#[arg(long)]
all: bool,
},
/// Unmount configured drive pairs.
Unmount {
#[arg(long)]
name: Option<String>,
#[arg(long)]
all: bool,
},
/// Shows the current mount status.
Status {
#[arg(long)]
name: Option<String>,
/// Output as JSON instead of text - for scripts.
#[arg(long)]
json: bool,
},
/// 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 },
}
/// Führt das per `Cli` geparste Subcommand aus.
pub async fn dispatch(cli: Cli) -> anyhow::Result<()> {
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(())
}
}
}
+67
View File
@@ -0,0 +1,67 @@
//! `smart-mount mount` / `smart-mount unmount`.
use smart_mount::config::{self, DrivePair};
use smart_mount::db::credentials::CredentialStore;
use smart_mount::reconcile;
fn select_pairs(
cfg: &smart_mount::config::AppConfig,
name: Option<&str>,
all: bool,
) -> anyhow::Result<Vec<DrivePair>> {
if let Some(name) = name {
return Ok(vec![config::pairs::find_pair(cfg, name)?]);
}
if !all {
anyhow::bail!("Please specify '--name <id>' or '--all'.");
}
Ok(cfg.pairs.clone())
}
pub async fn run_mount(name: Option<String>, all: bool) -> anyhow::Result<()> {
crate::cli::require_root("mount")?;
let cfg = config::pairs::load()?;
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
if pairs.is_empty() {
println!("No matching drive pairs found.");
return Ok(());
}
let creds = CredentialStore::open().await?;
for pair in &pairs {
let outcome = reconcile::reconcile_pair(pair, &cfg.settings, &creds).await;
print_outcome(&outcome);
}
Ok(())
}
pub async fn run_unmount(name: Option<String>, all: bool) -> anyhow::Result<()> {
crate::cli::require_root("unmount")?;
let cfg = config::pairs::load()?;
let pairs = select_pairs(&cfg, name.as_deref(), all)?;
if pairs.is_empty() {
println!("No matching drive pairs found.");
return Ok(());
}
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),
}
}
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(),
Action::MountedCloud => "mounted cloud".to_string(),
Action::SwitchedToLocal => "switched to local".to_string(),
Action::SwitchedToCloud => "switched to cloud".to_string(),
Action::Failed(e) => format!("ERROR: {e}"),
};
println!("{} ({}): {action_str}", outcome.pair_name, outcome.pair_id);
}
+54
View File
@@ -0,0 +1,54 @@
//! `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(())
}
+104
View File
@@ -0,0 +1,104 @@
//! `smart-mount status`.
use serde::Serialize;
use smart_mount::config;
use smart_mount::db::credentials::Side;
use smart_mount::mount::target;
use smart_mount::network;
#[derive(Serialize)]
struct PairStatus {
id: String,
name: String,
mount_point: String,
active: Option<&'static str>,
local_source: String,
local_reachable: bool,
cloud_source: String,
cloud_reachable: bool,
}
pub async fn run(name: Option<String>, json: bool) -> anyhow::Result<()> {
let cfg = config::pairs::load()?;
let pairs = match &name {
Some(name) => vec![config::pairs::find_pair(&cfg, name)?],
None => cfg.pairs.clone(),
};
if pairs.is_empty() {
if json {
println!("[]");
} else {
println!("No drive pairs configured.");
}
return Ok(());
}
let statuses: Vec<PairStatus> = pairs
.iter()
.map(|pair| {
let active = match target::active_side(pair) {
Some(Side::Local) => Some("local"),
Some(Side::Cloud) => Some("cloud"),
None => None,
};
// Die lokale Adresse (bei MAC-Adressierung ein `mac2ip`-Subprozessaufruf) wird nur
// einmal pro Paar aufgelöst und für Quelle UND Erreichbarkeitsprüfung
// 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);
let local_source = resolved_local_ip
.as_ref()
.map(|ip| target::format_local_source(&pair.local, *ip))
.unwrap_or_else(|_| "unresolved".to_string());
let local_reachable = resolved_local_ip
.as_ref()
.map(|ip| network::is_reachable(&ip.to_string()))
.unwrap_or(false);
PairStatus {
id: pair.id.clone(),
name: pair.name.clone(),
mount_point: pair.mount_point.display().to_string(),
active,
local_source,
local_reachable,
cloud_source: target::cloud_source(&pair.cloud),
cloud_reachable: network::is_reachable(&pair.cloud.host_or_url),
}
})
.collect();
if json {
println!("{}", serde_json::to_string_pretty(&statuses)?);
return Ok(());
}
for status in &statuses {
println!("{} ({})", status.name, status.id);
println!(" Symlink: {}", status.mount_point);
println!(" Mounted: {}", status.active.unwrap_or("not mounted"));
println!(
" Local: {} [{}]",
status.local_source,
reachable_str(status.local_reachable)
);
println!(
" Cloud: {} [{}]",
status.cloud_source,
reachable_str(status.cloud_reachable)
);
}
Ok(())
}
fn reachable_str(reachable: bool) -> &'static str {
if reachable {
"reachable"
} else {
"unreachable"
}
}
+27
View File
@@ -0,0 +1,27 @@
//! `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;
pub async fn run() -> anyhow::Result<()> {
crate::cli::require_root("watch")?;
let cfg = config::pairs::load()?;
let creds = CredentialStore::open().await?;
let outcomes = reconcile::watch_once(&cfg, &creds).await;
let mut had_failure = false;
for outcome in &outcomes {
print_outcome(outcome);
if matches!(outcome.action, Action::Failed(_)) {
had_failure = true;
}
}
if had_failure {
anyhow::bail!("at least one drive pair could not be reconciled");
}
Ok(())
}
-178
View File
@@ -1,178 +0,0 @@
//! Modul für die Konfigurationsverwaltung der Anwendung.
//!
//! Dieses Modul stellt die Funktionalität zum Laden, Speichern und Verwalten
//! der Anwendungskonfiguration bereit. Die Konfiguration wird in einer Datei gespeichert
//! und beim Programmstart automatisch geladen.
use crate::program;
use crate::sudo::is_run_as_root;
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
/// Hauptkonfigurationsstruktur der Anwendung.
///
/// Enthält alle Konfigurationseinstellungen, aufgeteilt in verschiedene Bereiche.
#[derive(Serialize, Deserialize, Clone)]
pub struct AppConfig {
/// Allgemeine Einstellungen
pub general: General,
/// Lokale Einbindungseinstellungen
pub local: Local,
/// Remote-Einbindungseinstellungen
pub remote: Remote,
/// Lokaler Zwischenspeicher
pub storage: Option<Storage>,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
general: General::default(),
local: Local::default(),
remote: Remote::default(),
storage: None,
}
}
}
/// Allgemeine Konfigurationseinstellungen.
#[derive(Serialize, Deserialize, Clone)]
pub struct General {
/// Log-Level für die Anwendung
pub log_level: String,
/// Pfad zum Einhängepunkt
pub mount_point: String,
}
impl Default for General {
fn default() -> Self {
Self {
log_level: "info".to_string(),
mount_point: get_default_mount_path(),
}
}
}
/// Konfiguration für lokale Einbindungen.
#[derive(Serialize, Deserialize, Clone)]
pub struct Local {
/// Typ der lokalen Einbindung (z.B. "nfs")
pub mount_type: String,
/// Pfad zur lokalen Einbindung
pub mount_path: String,
/// MAC-Adresse des lokalen Geräts
pub device_mac: String,
}
impl Default for Local {
fn default() -> Self {
Self {
mount_type: "nfs".to_string(),
mount_path: "".to_string(),
device_mac: "".to_string(),
}
}
}
/// Konfiguration für Remote-Einbindungen.
#[derive(Serialize, Deserialize, Clone)]
pub struct Remote {
/// Typ der Remote-Einbindung (z.B. "webdav")
pub mount_type: String,
/// Pfad zur Remote-Einbindung
pub mount_path: String,
/// Benutzername für Remote-Zugriff
pub username: String,
/// Passwort für Remote-Zugriff
pub password: String,
}
impl Default for Remote {
fn default() -> Self {
Self {
mount_type: "webdav".to_string(),
mount_path: "".to_string(),
username: "".to_string(),
password: "".to_string(),
}
}
}
/// Zwischenspeicher für das Programm.
#[derive(Serialize, Deserialize, Clone)]
pub struct Storage {
pub device_ip: String,
}
impl Default for Storage {
fn default() -> Self {
Self {
device_ip: "".to_string(),
}
}
}
/// Ermittelt den Standard-Einhängepunkt basierend auf dem Betriebssystem.
fn get_default_mount_path() -> String {
#[cfg(not(any(target_os = "windows")))]
{
"/media/smart_mount".to_string()
}
#[cfg(any(target_os = "windows"))]
{
let drives = ('C'..='Z').collect::<Vec<char>>();
for drive in drives {
let drive_string = format!("{}:", drive);
if !std::path::Path::new(&drive_string).exists() {
return format!("{}:", drive);
}
}
"H".to_string()
}
}
static CONFIG_NAME: &str = "config";
static CONFIG: OnceLock<AppConfig> = OnceLock::new();
/// Gibt die aktuelle Konfiguration zurück.
///
/// Lädt die Konfiguration beim ersten Aufruf und speichert sie zwischen.
/// Nachfolgende Aufrufe geben die gespeicherte Konfiguration zurück.
pub fn get_config() -> &'static AppConfig {
CONFIG.get_or_init(|| {
load_config()
})
}
/// Modifiziert die aktuelle Konfiguration mit der übergebenen Mutator-Funktion.
///
/// Die Funktion lädt die Konfiguration neu von der Festplatte, wendet die Mutator-Funktion an
/// und speichert die geänderte Konfiguration anschließend wieder.
pub fn modify_config<F>(mutator: F)
where
F: FnOnce(&mut AppConfig),
{
// Immer frisch von Disk laden, damit Änderungen konsistent sind
let mut cfg = load_config();
mutator(&mut cfg);
save_config(cfg);
}
/// Lädt die Konfiguration aus der Konfigurationsdatei.
fn load_config() -> AppConfig {
#[cfg(target_os = "linux")]
if is_run_as_root() {
return confy::load_path(format!("/etc/{}/{}.toml", program::program_name(), CONFIG_NAME)).unwrap_or_default();
}
confy::load(&*program::program_name(), CONFIG_NAME).unwrap_or_default()
}
/// Speichert die übergebene Konfiguration in der Konfigurationsdatei.
fn save_config(config: AppConfig) {
#[cfg(target_os = "linux")]
if is_run_as_root() {
confy::store_path(format!("/etc/{}/{}.toml", program::program_name(), CONFIG_NAME), config).unwrap();
return;
}
confy::store(&*program::program_name(), CONFIG_NAME, config).unwrap();
}
+23
View File
@@ -0,0 +1,23 @@
//! Konfigurationsverwaltung: Schema + CRUD auf Laufwerkspaaren, aufbauend auf `config-ctdra`.
pub mod pairs;
pub mod schema;
pub use schema::{
AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountKind,
};
/// Initialisiert `config-ctdra`: Dateiname und - unabhängig von den Rechten des aufrufenden
/// Prozesses - immer der System-Pfad (`/etc/<programmname>/config.toml`). Es gibt nur noch
/// eine einzige, systemweite Konfiguration (smart-mount läuft ausschließlich als root/
/// System-Dienst, siehe [`crate::systemd`]) statt einer je nach Aufrufer wechselnden
/// Root-/Nutzerpfad-Auflösung - `drive add`/`edit`/`remove` verlangen deshalb explizit Root
/// (siehe `cli::require_root`), auch wenn sie nicht direkt mounten.
///
/// Muss vor dem ersten `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter
/// Zustand).
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));
}
+58
View File
@@ -0,0 +1,58 @@
//! CRUD-Operationen auf [`AppConfig::pairs`], atomar über `config_ctdra::modify`.
use crate::config::schema::{AppConfig, DrivePair};
use crate::error::{Error, Result};
/// Lädt die aktuelle Konfiguration frisch von der Platte.
pub fn load() -> Result<AppConfig> {
Ok(config_ctdra::load::<AppConfig>()?)
}
/// Fügt ein neues Laufwerkspaar hinzu (load-mutate-store in einem atomaren Schritt).
pub fn add_pair(pair: DrivePair) -> Result<AppConfig> {
Ok(config_ctdra::modify::<AppConfig, _>(|cfg| {
cfg.pairs.push(pair.clone());
})?)
}
/// Ersetzt ein bestehendes Laufwerkspaar (Vergleich über `id`).
pub fn update_pair(pair: DrivePair) -> Result<AppConfig> {
let id = pair.id.clone();
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();
}
})?;
if !updated.pairs.iter().any(|p| p.id == id) {
return Err(Error::PairNotFound(id));
}
Ok(updated)
}
/// Entfernt ein Laufwerkspaar per ID.
pub fn remove_pair(id: &str) -> Result<AppConfig> {
// 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
// ändern, was hier sonst zu einem falschen `PairNotFound` trotz erfolgreichem Entfernen
// (oder umgekehrt) führen könnte.
let removed = std::cell::Cell::new(false);
let updated = config_ctdra::modify::<AppConfig, _>(|cfg| {
let before_len = cfg.pairs.len();
cfg.pairs.retain(|p| p.id != id);
removed.set(cfg.pairs.len() != before_len);
})?;
if !removed.get() {
return Err(Error::PairNotFound(id.to_string()));
}
Ok(updated)
}
/// Sucht ein Laufwerkspaar per ID oder (fallback) exaktem Namen.
pub fn find_pair(cfg: &AppConfig, id_or_name: &str) -> Result<DrivePair> {
cfg.pairs
.iter()
.find(|p| p.id == id_or_name || p.name == id_or_name)
.cloned()
.ok_or_else(|| Error::PairNotFound(id_or_name.to_string()))
}
+202
View File
@@ -0,0 +1,202 @@
//! Konfigurationsschema: globale Einstellungen + Liste konfigurierter Laufwerkspaare.
//!
//! Passwörter sind hier bewusst NICHT enthalten - sie leben verschlüsselt in der
//! Turso-Datenbank (siehe [`crate::db::credentials`]), niemals im Klartext in der TOML-Datei.
use std::net::Ipv4Addr;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene
/// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei
/// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie
/// persistieren müssen. Jedes Paar bekommt direkt darunter sein eigenes, nach seinem Namen
/// benanntes Unterverzeichnis (`/run/media/<Name>`, siehe `cli::drive::default_mount_point`) -
/// kein zusätzlicher `smart-mount`-Zwischenordner, damit der Ordnername beim Durchsuchen von
/// `/run/media` sofort erkennbar ist. Die Trennung nach Linux-Nutzer passiert bei Bedarf über
/// die `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über
/// unterschiedliche Mountpoint-Namensräume - smart-mount läuft ausschließlich als
/// root/System-Dienst (siehe [`crate::systemd`]).
fn default_mount_base_dir() -> PathBuf {
PathBuf::from("/run/media")
}
fn default_log_level() -> String {
"info".to_string()
}
fn default_watch_interval_secs() -> u64 {
120
}
fn default_mac2ip_binary() -> String {
"mac2ip".to_string()
}
/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` immer unter
/// `/etc/smart-mount/config.toml` (siehe [`crate::config::init`]).
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct AppConfig {
#[serde(default)]
pub settings: GlobalSettings,
#[serde(default)]
pub pairs: Vec<DrivePair>,
}
/// Globale, paarunabhängige Einstellungen.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GlobalSettings {
#[serde(default = "default_mount_base_dir")]
pub mount_base_dir: PathBuf,
#[serde(default = "default_log_level")]
pub log_level: String,
/// Periode, mit der `smart-mount watch` über den Cron-Fallback (`smart-mount service
/// crontab`, siehe [`crate::systemd`]) ausgeführt werden soll. Der paketierte systemd-Timer
/// hat ein fest eingebautes Intervall (siehe `packaging/systemd/smart-mount-watch.timer`)
/// und liest dieses Feld nicht.
#[serde(default = "default_watch_interval_secs")]
pub watch_interval_secs: u64,
/// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad).
#[serde(default = "default_mac2ip_binary")]
pub mac2ip_binary: String,
}
impl Default for GlobalSettings {
fn default() -> Self {
Self {
mount_base_dir: default_mount_base_dir(),
log_level: default_log_level(),
watch_interval_secs: default_watch_interval_secs(),
mac2ip_binary: default_mac2ip_binary(),
}
}
}
/// Ein konfiguriertes Laufwerkspaar: eine lokale (LAN) und eine Cloud-Seite, die dasselbe
/// logische Laufwerk repräsentieren. Nur eine Seite ist zu einem Zeitpunkt an `mount_point`
/// eingebunden - siehe [`crate::reconcile`].
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DrivePair {
/// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten sowie das Mount-Unterverzeichnis.
pub id: String,
pub name: String,
#[serde(default = "default_true")]
pub enabled: bool,
/// Optional: der Linux-Benutzername, der vollen Zugriff (uid/gid) auf dieses Paar
/// bekommen soll (siehe [`crate::mount::target::build_target`]). Ohne `owner_user` gehört
/// der Mount root.
#[serde(default)]
pub owner_user: Option<String>,
pub mount_point: PathBuf,
pub local: LocalSide,
pub cloud: CloudSide,
}
fn default_true() -> bool {
true
}
/// Die lokale (LAN-)Seite eines Laufwerkspaars.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LocalSide {
pub kind: MountKind,
pub address: LocalAddress,
/// Freigabename/Exportpfad (SMB-Share, NFS-Export, WebDAV-Pfadsegment).
pub share: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub extra_options: Vec<String>,
}
/// Die Cloud-Seite eines Laufwerkspaars.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct CloudSide {
pub kind: MountKind,
/// Volle URL (WebDAV) bzw. Server-Adresse (SMB/NFS).
pub host_or_url: String,
pub share: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub extra_options: Vec<String>,
}
/// Unterstützte Mount-Verfahren. Jede Variante wird dynamisch auf ein
/// [`crate::mount::MountBackend`] dispatcht - siehe dort für die "nur bei Bedarf laden"-Logik.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Hash, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
#[value(rename_all = "lowercase")]
pub enum MountKind {
WebDav,
Smb,
Nfs,
}
impl MountKind {
pub fn as_str(&self) -> &'static str {
match self {
MountKind::WebDav => "webdav",
MountKind::Smb => "smb",
MountKind::Nfs => "nfs",
}
}
}
/// Adressierung der lokalen Seite: entweder direkt per IP oder per MAC-Adresse, die zur
/// Laufzeit über `mac2ip` aufgelöst wird (siehe [`crate::network::mac2ip`]).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(rename_all = "lowercase")]
pub enum LocalAddress {
Ip(Ipv4Addr),
Mac(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn app_config_round_trips_through_toml() {
let cfg = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![DrivePair {
id: "pair-1".into(),
name: "NAS".into(),
enabled: true,
owner_user: Some("dragon".into()),
mount_point: PathBuf::from("/home/dragon/smart-mount/pair-1"),
local: LocalSide {
kind: MountKind::WebDav,
address: LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into()),
share: "/dav".into(),
username: Some("nasuser".into()),
extra_options: vec![],
},
cloud: CloudSide {
kind: MountKind::WebDav,
host_or_url: "https://cloud.example.com/remote.php/dav/files/dragon".into(),
share: "/".into(),
username: Some("dragon".into()),
extra_options: vec![],
},
}],
};
let toml_str = toml::to_string_pretty(&cfg).expect("serialize");
let round_tripped: AppConfig = toml::from_str(&toml_str).expect("deserialize");
assert_eq!(round_tripped.pairs.len(), 1);
assert_eq!(round_tripped.pairs[0].id, "pair-1");
match &round_tripped.pairs[0].local.address {
LocalAddress::Mac(mac) => assert_eq!(mac, "aa:bb:cc:dd:ee:ff"),
LocalAddress::Ip(_) => panic!("expected Mac variant"),
}
}
#[test]
fn default_mount_base_dir_uses_run_media() {
assert_eq!(default_mount_base_dir(), PathBuf::from("/run/media"));
}
}
+90
View File
@@ -0,0 +1,90 @@
//! Master-Schlüssel-Auflösung für die Zugangsdaten-Verschlüsselung.
//!
//! Immer die Schlüsseldatei: SmartMount läuft ausschließlich als root/System-Dienst (siehe
//! [`crate::systemd`]), für den es kein Nutzer-Keyring (GNOME Keyring/KWallet über
//! secret-service) gibt.
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
const KEY_FILE_NAME: &str = "master.key";
const KEY_LEN: usize = 32;
/// Ermittelt (und erzeugt bei Bedarf) den 256-Bit-Master-Schlüssel für die
/// Zugangsdaten-Verschlüsselung.
pub fn resolve_master_key() -> Result<[u8; 32]> {
file_key::load_or_create(&key_file_path())
}
fn key_file_path() -> PathBuf {
let config_path = config_ctdra::get_config_path();
config_path
.parent()
.map(|dir| dir.join(KEY_FILE_NAME))
.unwrap_or_else(|| PathBuf::from(KEY_FILE_NAME))
}
mod file_key {
use super::*;
pub fn load_or_create(path: &Path) -> Result<[u8; 32]> {
if path.exists() {
return read(path);
}
create(path)
}
fn read(path: &Path) -> Result<[u8; 32]> {
let mut file = File::open(path).map_err(|e| Error::io(path, e))?;
let mut buf = Vec::with_capacity(KEY_LEN);
file.read_to_end(&mut buf).map_err(|e| Error::io(path, e))?;
if buf.len() != KEY_LEN {
return Err(Error::Crypto(format!(
"key file '{}' has unexpected length ({} instead of {KEY_LEN} bytes)",
path.display(),
buf.len()
)));
}
let mut key = [0u8; KEY_LEN];
key.copy_from_slice(&buf);
Ok(key)
}
fn create(path: &Path) -> Result<[u8; 32]> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
}
let mut key = [0u8; KEY_LEN];
fill_random(&mut key)?;
#[cfg(unix)]
let mut opts = OpenOptions::new();
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
#[cfg(not(unix))]
let mut opts = OpenOptions::new();
match opts.write(true).create_new(true).open(path) {
Ok(mut file) => {
file.write_all(&key).map_err(|e| Error::io(path, e))?;
Ok(key)
}
// Another process won the race and created the file first; use its key instead of
// silently generating our own (which would desynchronize the two processes' keys).
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => read(path),
Err(e) => Err(Error::io(path, e)),
}
}
fn fill_random(buf: &mut [u8]) -> Result<()> {
::getrandom::fill(buf)
.map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))
}
}
+84
View File
@@ -0,0 +1,84 @@
//! Verschlüsselung von Zugangsdaten vor der Ablage in der Turso-Datenbank.
//!
//! Turso hat aktuell keine produktionsreife eingebaute Verschlüsselung (nur ein
//! experimentelles, unauditiertes Feature) - Passwörter werden daher hier selbst mit
//! AES-256-GCM verschlüsselt, bevor sie als Ciphertext-BLOB in die DB geschrieben werden.
pub mod key;
use aes_gcm::aead::{Aead, KeyInit};
// `aead::Nonce<A>` ist über den AEAD-Algorithmus-Typ parametrisiert (löst intern
// `<A as AeadCore>::NonceSize` auf) - anders als `aes_gcm::Nonce<NonceSize>`, das direkt
// über die Array-Länge parametrisiert ist. Für `Nonce::<Aes256Gcm>` brauchen wir Ersteres.
use aes_gcm::aead::Nonce;
use aes_gcm::{Aes256Gcm, Key};
use crate::error::{Error, Result};
/// AES-GCM-Nonce-Länge in Byte (96 Bit, Standard für AES-256-GCM).
pub const NONCE_LEN: usize = 12;
/// Verschlüsselt `plaintext` mit dem gegebenen 256-Bit-Schlüssel.
///
/// Gibt `(ciphertext, nonce)` zurück - beide werden zusammen mit dem Datensatz gespeichert;
/// der Schlüssel selbst wird niemals in der Datenbank abgelegt.
///
/// Erzeugt die Nonce bewusst über eine eigene, direkte `getrandom`-Abhängigkeit statt über
/// `aes_gcm::aead::rand_core::OsRng` - Letzteres ist seit aes-gcm 0.11 nicht mehr ohne
/// Weiteres erreichbar (rand_core 0.9s `OsRng` steckt hinter dem `os_rng`-Feature, das über
/// 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>)> {
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key));
let mut nonce_bytes = [0u8; NONCE_LEN];
getrandom::fill(&mut nonce_bytes)
.map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))?;
let nonce: Nonce<Aes256Gcm> = nonce_bytes.into();
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| Error::Crypto(format!("Encryption failed: {e}")))?;
Ok((ciphertext, nonce.to_vec()))
}
/// Entschlüsselt einen zuvor mit [`encrypt`] erzeugten Ciphertext.
pub fn decrypt(ciphertext: &[u8], nonce: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
if nonce.len() != NONCE_LEN {
return Err(Error::Crypto(format!(
"invalid nonce length: expected {NONCE_LEN}, got {}",
nonce.len()
)));
}
let cipher = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key));
let nonce: Nonce<Aes256Gcm> = Nonce::<Aes256Gcm>::try_from(nonce)
.map_err(|_| Error::Crypto("invalid nonce".to_string()))?;
cipher
.decrypt(&nonce, ciphertext)
.map_err(|e| Error::Crypto(format!("Decryption failed: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encrypt_decrypt_round_trip() {
let key = [7u8; 32];
let plaintext = b"correct horse battery staple";
let (ciphertext, nonce) = encrypt(plaintext, &key).expect("encrypt");
assert_ne!(ciphertext, plaintext);
let decrypted = decrypt(&ciphertext, &nonce, &key).expect("decrypt");
assert_eq!(decrypted, plaintext);
}
#[test]
fn decrypt_fails_with_wrong_key() {
let key = [1u8; 32];
let other_key = [2u8; 32];
let (ciphertext, nonce) = encrypt(b"secret", &key).expect("encrypt");
assert!(decrypt(&ciphertext, &nonce, &other_key).is_err());
}
}
+140
View File
@@ -0,0 +1,140 @@
//! Verschlüsselte Zugangsdaten-CRUD auf der `credentials`-Tabelle.
use crate::crypto;
use crate::crypto::key::resolve_master_key;
use crate::error::Result;
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
/// Welche Seite eines Laufwerkspaars die Zugangsdaten betreffen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
Local,
Cloud,
}
impl Side {
pub fn as_str(&self) -> &'static str {
match self {
Side::Local => "local",
Side::Cloud => "cloud",
}
}
}
/// Entschlüsselte Zugangsdaten für eine Seite eines Laufwerkspaars.
#[derive(Debug, Clone)]
pub struct Credential {
pub username: Option<String>,
pub domain: Option<String>,
pub password: String,
}
/// Dünner Wrapper um die `credentials`-Tabelle; ver-/entschlüsselt transparent mit dem
/// per [`resolve_master_key`] ermittelten Schlüssel.
pub struct CredentialStore {
conn: turso::Connection,
}
impl CredentialStore {
/// Öffnet die Datenbank und initialisiert das Schema bei Bedarf.
pub async fn open() -> Result<Self> {
Ok(Self {
conn: crate::db::open().await?,
})
}
/// Speichert (oder ersetzt) die Zugangsdaten für `pair_id`/`side`.
pub async fn put(
&self,
pair_id: &str,
side: Side,
username: Option<&str>,
domain: Option<&str>,
password: &str,
) -> Result<()> {
let key = resolve_master_key()?;
let (ciphertext, nonce) = crypto::encrypt(password.as_bytes(), &key)?;
let now = now_unix();
self.conn
.execute(
"INSERT INTO credentials (pair_id, side, username, domain, ciphertext, nonce, updated_at) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
ON CONFLICT(pair_id, side) DO UPDATE SET \
username = excluded.username, domain = excluded.domain, \
ciphertext = excluded.ciphertext, nonce = excluded.nonce, updated_at = excluded.updated_at",
(
pair_id.to_string(),
side.as_str().to_string(),
username.map(str::to_string),
domain.map(str::to_string),
ciphertext,
nonce,
now,
),
)
.await?;
Ok(())
}
/// 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>> {
let mut rows = self
.conn
.query(
"SELECT username, domain, ciphertext, nonce FROM credentials WHERE pair_id = ?1 AND side = ?2",
(pair_id.to_string(), side.as_str().to_string()),
)
.await?;
let Some(row) = rows.next().await? else {
return Ok(None);
};
let username: Option<String> = row.get(0)?;
let domain: Option<String> = row.get(1)?;
let ciphertext: Vec<u8> = row.get(2)?;
let nonce: Vec<u8> = row.get(3)?;
let key = resolve_master_key()?;
let plaintext = crypto::decrypt(&ciphertext, &nonce, &key)?;
let password = String::from_utf8(plaintext).map_err(|e| {
crate::error::Error::Crypto(format!("password is not valid UTF-8: {e}"))
})?;
Ok(Some(Credential {
username,
domain,
password,
}))
}
/// 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<()> {
match side {
Some(side) => {
self.conn
.execute(
"DELETE FROM credentials WHERE pair_id = ?1 AND side = ?2",
(pair_id.to_string(), side.as_str().to_string()),
)
.await?;
}
None => {
self.conn
.execute(
"DELETE FROM credentials WHERE pair_id = ?1",
(pair_id.to_string(),),
)
.await?;
}
}
Ok(())
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Lokale Turso-Datenbank für verschlüsselt gespeicherte Zugangsdaten.
pub mod credentials;
use std::path::PathBuf;
use crate::error::{Error, Result};
const DB_FILE_NAME: &str = "smart-mount.db";
/// Pfad zur Datenbankdatei: dasselbe Verzeichnis wie die Konfigurationsdatei, folgt also
/// automatisch derselben Root-/User-Auflösung wie `config-ctdra`.
pub fn resolve_db_path() -> PathBuf {
let config_path = config_ctdra::get_config_path();
config_path
.parent()
.map(|dir| dir.join(DB_FILE_NAME))
.unwrap_or_else(|| PathBuf::from(DB_FILE_NAME))
}
/// Öffnet (und initialisiert bei Bedarf) die lokale Datenbank am aufgelösten Pfad.
pub async fn open() -> Result<turso::Connection> {
let path = resolve_db_path();
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir)
.await
.map_err(|e| Error::io(dir, e))?;
}
let db = turso::Builder::new_local(path.to_string_lossy().as_ref())
.build()
.await?;
let conn = db.connect()?;
init_schema(&conn).await?;
Ok(conn)
}
async fn init_schema(conn: &turso::Connection) -> Result<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS credentials (\
pair_id TEXT NOT NULL, \
side TEXT NOT NULL CHECK(side IN ('local','cloud')), \
username TEXT, \
domain TEXT, \
ciphertext BLOB NOT NULL, \
nonce BLOB NOT NULL, \
updated_at INTEGER NOT NULL, \
PRIMARY KEY (pair_id, side)\
)",
(),
)
.await?;
Ok(())
}
+230
View File
@@ -0,0 +1,230 @@
//! 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.
use std::collections::HashSet;
use crate::config::{AppConfig, LocalAddress, MountKind};
use crate::mount;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStatus {
Ok,
Warn,
Fail,
}
#[derive(Debug)]
pub struct CheckResult {
pub label: String,
pub status: CheckStatus,
pub detail: String,
}
fn ok(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
CheckResult {
label: label.into(),
status: CheckStatus::Ok,
detail: detail.into(),
}
}
fn warn(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
CheckResult {
label: label.into(),
status: CheckStatus::Warn,
detail: detail.into(),
}
}
fn fail(label: impl Into<String>, detail: impl Into<String>) -> CheckResult {
CheckResult {
label: label.into(),
status: CheckStatus::Fail,
detail: detail.into(),
}
}
/// Führt alle Checks gegen die aktuelle Konfiguration aus.
pub fn run_checks(cfg: &AppConfig) -> Vec<CheckResult> {
let mut results = Vec::new();
results.push(check_timeout_binary());
results.push(check_scheduler());
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));
}
}
if uses_mac_addressing(cfg) {
results.push(check_binary(
"mac2ip",
&cfg.settings.mac2ip_binary,
"install mac2ip (private tool, see README)",
));
results.push(check_binary("nmap", "nmap", "install package 'nmap'"));
}
results
}
fn used_mount_kinds(cfg: &AppConfig) -> HashSet<MountKind> {
let mut set = HashSet::new();
for pair in &cfg.pairs {
set.insert(pair.local.kind);
set.insert(pair.cloud.kind);
}
set
}
fn uses_mac_addressing(cfg: &AppConfig) -> bool {
cfg.pairs
.iter()
.any(|p| matches!(p.local.address, LocalAddress::Mac(_)))
}
fn check_timeout_binary() -> CheckResult {
if mount::binary_available("timeout") {
ok(
"timeout (coreutils)",
"found - protects mount/umount against hanging indefinitely",
)
} else {
fail(
"timeout (coreutils)",
"not found - mount/umount calls would block indefinitely, should be present on every Linux system",
)
}
}
fn check_binary(name: &str, binary: &str, install_hint: &str) -> CheckResult {
if mount::binary_available(binary) {
ok(name, format!("'{binary}' found"))
} else {
fail(name, format!("'{binary}' not found - {install_hint}"))
}
}
fn check_backend(kind: MountKind) -> 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()),
}
}
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");
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",
)
}
}
fn check_mount_base_dir(dir: &std::path::Path) -> CheckResult {
if dir.is_dir() {
ok("mount_base_dir", format!("'{}' exists", dir.display()))
} else if dir.parent().is_some_and(std::path::Path::is_dir) {
warn(
"mount_base_dir",
format!(
"'{}' does not exist yet, will be created on first mount",
dir.display()
),
)
} else {
fail(
"mount_base_dir",
format!(
"'{}' does not exist and its parent directory is also missing",
dir.display()
),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide};
use std::net::Ipv4Addr;
fn sample_pair(owner_user: Option<&str>, mac: bool) -> DrivePair {
DrivePair {
id: "pair-1".into(),
name: "Test".into(),
enabled: true,
owner_user: owner_user.map(str::to_string),
mount_point: "/media/smart-mount/pair-1".into(),
local: LocalSide {
kind: MountKind::Nfs,
address: if mac {
LocalAddress::Mac("aa:bb:cc:dd:ee:ff".into())
} else {
LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5))
},
share: "share".into(),
username: None,
extra_options: vec![],
},
cloud: CloudSide {
kind: MountKind::WebDav,
host_or_url: "https://cloud.example.com/dav".into(),
share: "/".into(),
username: None,
extra_options: vec![],
},
}
}
#[test]
fn used_mount_kinds_collects_both_sides_across_pairs() {
let cfg = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(None, false)],
};
let kinds = used_mount_kinds(&cfg);
assert!(kinds.contains(&MountKind::Nfs));
assert!(kinds.contains(&MountKind::WebDav));
assert!(!kinds.contains(&MountKind::Smb));
}
#[test]
fn uses_mac_addressing_detects_mac_pairs() {
let with_mac = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(None, true)],
};
let without_mac = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(None, false)],
};
assert!(uses_mac_addressing(&with_mac));
assert!(!uses_mac_addressing(&without_mac));
}
#[test]
fn empty_config_still_runs_global_checks_without_panicking() {
let cfg = AppConfig::default();
let results = run_checks(&cfg);
assert!(results.iter().any(|r| r.label.contains("timeout")));
assert!(results.iter().any(|r| r.label == "Scheduler"));
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Zentraler Fehlertyp für alle Bibliotheksmodule.
use std::path::PathBuf;
/// Sammelfehler für alle `smart-mount`-Bibliotheksmodule.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Configuration error: {0}")]
Config(#[from] config_ctdra::ConfyError),
#[error("Database error: {0}")]
Db(#[from] turso::Error),
#[error("Encryption error: {0}")]
Crypto(String),
#[error("Key storage error: {0}")]
Keyring(String),
#[error("I/O error on '{path}': {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("Drive pair '{0}' not found")]
PairNotFound(String),
#[error("Mount backend '{backend}' not available: {reason}")]
BackendUnavailable {
backend: &'static str,
reason: String,
},
#[error("Mount command failed ({context}): {stderr}")]
MountFailed { context: String, stderr: String },
#[error("mac2ip resolution failed for MAC {mac}: {reason}")]
Mac2Ip { mac: String, reason: String },
#[error("{0}")]
Other(String),
}
/// Ergebnistyp-Alias für `smart-mount`-Bibliotheksfunktionen.
pub type Result<T> = std::result::Result<T, Error>;
impl Error {
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
Self::Io {
path: path.into(),
source,
}
}
}
-111
View File
@@ -1,111 +0,0 @@
use crate::config::get_config;
use crate::log::{log, LogLevel};
use std::process::Command;
/// Speichert die Zugangsdaten für einen WebDAV-Mount-Punkt.
///
/// # Parameter
/// * `mount_path` - Der Netzwerkpfad zum WebDAV-Server
/// * `username` - Der Benutzername für den WebDAV-Zugriff
/// * `password` - Das Passwort für den WebDAV-Zugriff
pub fn save_credentials_webdav() {
let config = get_config();
#[cfg(target_os = "windows")]
{
let output = Command::new("powershell")
.args([
"-Command",
&format!("cmdkey /delete:{}", config.remote.mount_path)
])
.output()
.expect("Failed to delete credentials");
let output = Command::new("powershell")
.args([
"-Command",
&format!("cmdkey /add:{} /user:{} /pass:{}",
config.remote.mount_path,
config.remote.username,
config.remote.password
)
])
.output()
.expect("Failed to store credentials");
if !output.status.success() {
log(
"credentials",
"Could not store credentials in Windows Credential Manager",
LogLevel::Error,
);
log(
"credentials",
&String::from_utf8_lossy(&output.stderr),
LogLevel::Debug,
);
}
}
#[cfg(not(target_os = "windows"))]
{
if config.remote.username.is_empty() || config.remote.password.is_empty() {
log(
"credentials",
"No credentials found in config",
LogLevel::Error,
);
return;
}
let output = Command::new("touch")
.arg("/etc/davfs2/secrets")
.output()
.expect("Failed to create secrets file");
if !output.status.success() {
log(
"credentials",
"Could not create secrets file",
LogLevel::Error,
);
return;
}
let output = Command::new("chmod")
.arg("600")
.arg("/etc/davfs2/secrets")
.output()
.expect("Failed to set secrets file permissions");
if !output.status.success() {
log(
"credentials",
"Could not set secrets file permissions",
LogLevel::Error,
);
return;
}
let credentials = format!("{} \"{}\" \"{}\"\n",
config.general.mount_point,
config.remote.username,
config.remote.password
);
let mut content = std::fs::read_to_string("/etc/davfs2/secrets")
.unwrap_or_default();
content = content.lines()
.filter(|line| !line.starts_with(&config.general.mount_point))
.collect::<Vec<&str>>()
.join("\n");
if !content.is_empty() {
content.push('\n');
}
content.push_str(&credentials);
std::fs::write("/etc/davfs2/secrets", content)
.expect("Failed to write credentials");
}
}
-3
View File
@@ -1,3 +0,0 @@
pub mod mount;
pub mod mounted;
pub mod credentials;
-213
View File
@@ -1,213 +0,0 @@
use crate::filesystem::mounted::{/* is_mounted, */ FS_MOUNT_GUARD};
use crate::log::{LogLevel, log};
use std::process::Command;
use std::sync::{Mutex, OnceLock, RwLock};
use std::thread::sleep;
use std::time::Duration;
static GUARD_MOUNT: OnceLock<Mutex<()>> = OnceLock::new();
static GUARD_UNMOUNT: OnceLock<Mutex<()>> = OnceLock::new();
/// Führt die Einbindung eines Dateisystems durch.
///
/// # Parameter
/// * `mount_point` - Der lokale Verzeichnispfad, an dem das Dateisystem eingebunden werden soll
/// * `network_path` - Der Netzwerkpfad zum einzubindenden Dateisystem
/// * `mount_type` - Der Mount-Typ, z.B. "nfs" oder "davfs2"
pub fn mount(mount_point: &str, network_path: &str, mount_type: &str) {
// 1) Globalen Write-Lock halten (blockiert parallele Statusabfragen)
let _fs_write = FS_MOUNT_GUARD
.get_or_init(|| RwLock::new(()))
.write()
.expect("mount rwlock poisoned");
// 2) Danach funktionsspezifischen Mutex sperren (konsistente Lock-Reihenfolge!)
let m = GUARD_MOUNT.get_or_init(|| Mutex::new(()));
let _lock = m.lock().expect("Mutex poisoned");
log(
"mount",
&*format!(
"Mounting filesystem ({}) at \"{}\" as \"{}\"",
network_path, mount_point, mount_type
),
LogLevel::Info,
);
#[cfg(target_os = "windows")]
{
let output = Command::new("New-PSDrive")
.args([
"-Name",
&mount_point[0..1], // First letter for drive letter
"-PSProvider",
"FileSystem",
"-Root",
network_path,
"-Persist",
"-Type",
mount_type,
])
.output()
.expect("Failed to execute mount command");
if !output.status.success() {
log(
"mount",
&*format!(
"Filesystem ({}) couldn't be mounted at \"{}\" as \"{}\"",
network_path, mount_point, mount_type
),
LogLevel::Error,
);
log(
"mount",
&*String::from_utf8_lossy(&output.stderr),
LogLevel::Debug,
);
}
}
#[cfg(not(target_os = "windows"))]
{
// Verzeichnis vorbereiten
let output = Command::new("mkdir")
.arg("-p")
.arg(mount_point)
.output()
.expect("Failed to create mount point directory");
if output.status.success() {
log("mount", "Mountpoint created successfully!", LogLevel::Info);
} else {
log(
"mount",
&*String::from_utf8_lossy(&output.stderr),
LogLevel::Debug,
);
}
let output = Command::new("chmod")
.arg("777")
.arg(mount_point)
.output()
.expect("Failed to set mount point permissions");
if output.status.success() {
log(
"mount",
"Successfully set mount point permissions.",
LogLevel::Info,
);
} else {
log(
"mount",
&*String::from_utf8_lossy(&output.stderr),
LogLevel::Debug,
);
}
let output = Command::new("mount")
.arg("-t")
.arg(mount_type)
.arg(network_path)
.arg(mount_point)
.output()
.expect("Failed to execute mount command");
sleep(Duration::from_secs(1));
if output.status.success() {
log("mount", "Filesystem mounted successfully.", LogLevel::Info);
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
// Häufige „schon gemountet“/Busy-Indikatoren tolerant behandeln
let already_or_busy = stderr.contains("already mounted")
|| stderr.contains("is busy")
|| stderr.contains("Device or resource busy")
|| stderr.contains("EBUSY");
if already_or_busy {
log(
"mount",
"Filesystem appears to be already mounted or mountpoint is busy. Treating as no-op.",
LogLevel::Info,
);
log("mount", &*stderr, LogLevel::Debug);
return;
}
log(
"mount",
&*format!(
"Filesystem ({}) couldn't be mounted at \"{}\" as \"{}\"",
network_path, mount_point, mount_type
),
LogLevel::Error,
);
log("mount", &*stderr, LogLevel::Debug);
}
}
}
/// Hängt ein Dateisystem aus.
///
/// # Parameter
/// * `mount_point` - Der lokale Verzeichnispfad, von dem das Dateisystem ausgehängt werden soll
pub fn unmount(mount_point: &str) {
// 1) Globalen Write-Lock halten (blockiert parallele Statusabfragen)
let _fs_write = FS_MOUNT_GUARD
.get_or_init(|| RwLock::new(()))
.write()
.expect("mount rwlock poisoned");
// 2) Danach funktionsspezifischen Mutex sperren (konsistente Lock-Reihenfolge!)
let m = GUARD_UNMOUNT.get_or_init(|| Mutex::new(()));
let _lock = m.lock().expect("Mutex poisoned");
#[cfg(target_os = "windows")]
{
let output = Command::new("Remove-PSDrive")
.args([
"-Name",
&mount_point[0..1], // First letter for drive letter
"-Force",
])
.output()
.expect("Failed to execute unmount command");
if !output.status.success() {
log(
"unmount",
&*format!("Filesystem couldn't be unmounted from \"{}\"", mount_point),
LogLevel::Error,
);
log(
"unmount",
&*String::from_utf8_lossy(&output.stderr),
LogLevel::Debug,
);
}
}
#[cfg(not(target_os = "windows"))]
{
let output = Command::new("umount")
.arg(mount_point)
.output()
.expect("Failed to execute unmount command");
if !output.status.success() {
log(
"unmount",
&*format!("Filesystem couldn't be unmounted from \"{}\"", mount_point),
LogLevel::Error,
);
log(
"unmount",
&*String::from_utf8_lossy(&output.stderr),
LogLevel::Debug,
);
}
}
}
-167
View File
@@ -1,167 +0,0 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::sync::{OnceLock, RwLock};
// Globaler RW-Lock für Mount-Operationen und Statusabfragen
pub static FS_MOUNT_GUARD: OnceLock<RwLock<()>> = OnceLock::new();
fn guard() -> &'static RwLock<()> {
FS_MOUNT_GUARD.get_or_init(|| RwLock::new(()))
}
/// Überprüft, ob ein Dateisystem am angegebenen Mount-Point mit dem spezifizierten Mount-Typ eingebunden ist.
///
/// # Parameter
/// * `mount_point` - Der Pfad, an dem das Dateisystem eingebunden sein soll
/// * `mount_type` - Der erwartete Mount-Typ des Dateisystems (z.B. nfs, smb, davfs2)
///
/// # Rückgabewert
/// * `true` wenn das Dateisystem mit dem angegebenen Typ eingebunden ist
/// * `false` wenn das Dateisystem nicht oder mit einem anderen Typ eingebunden ist
pub fn is_mounted_as(mount_point: &str, mount_type: &str) -> bool {
// Während Statusabfragen nur Read-Lock halten
let _read_guard = guard().read().expect("mount rwlock poisoned");
#[cfg(target_os = "windows")]
{
use std::process::Command;
let drive_letter = &mount_point[0..1];
let output = Command::new("powershell")
.args([
"-Command",
&format!(
"(Get-PSDrive -Name {} -PSProvider 'FileSystem').Description",
drive_letter
),
])
.output()
.expect("Failed to execute get-psdrive command");
if !output.status.success() {
return false;
}
let drive_type = String::from_utf8_lossy(&output.stdout).trim().to_string();
drive_type == mount_type
}
#[cfg(not(target_os = "windows"))]
{
let norm_mp = normalize_mount_point(mount_point);
for entry in read_proc_mounts() {
let (mp, fstype, _opts, _src) = entry;
if mp == norm_mp {
if type_matches(mount_type, &fstype, &_opts) {
return true;
}
}
}
false
}
}
/// Überprüft, ob ein Dateisystem am angegebenen Mount-Point eingebunden ist.
///
/// # Parameter
/// * `mount_point` - Der Pfad, an dem das Dateisystem eingebunden sein soll
///
/// # Rückgabewert
/// * `true` wenn ein Dateisystem am angegebenen Pfad eingebunden ist
/// * `false` wenn kein Dateisystem eingebunden ist
pub fn is_mounted(mount_point: &str) -> bool {
// Während Statusabfragen nur Read-Lock halten
let _read_guard = guard().read().expect("mount rwlock poisoned");
#[cfg(target_os = "windows")]
{
use std::process::Command;
let drive_letter = &mount_point[0..1];
let output = Command::new("powershell")
.args([
"-Command",
&format!(
"(Get-PSDrive -Name {} -PSProvider 'FileSystem')",
drive_letter
),
])
.output()
.expect("Failed to execute get-psdrive command");
output.status.success()
}
#[cfg(not(target_os = "windows"))]
{
let norm_mp = normalize_mount_point(mount_point);
read_proc_mounts()
.into_iter()
.any(|(mp, _, _, _)| mp == norm_mp)
}
}
#[cfg(not(target_os = "windows"))]
fn read_proc_mounts() -> Vec<(String, String, String, String)> {
// Liefert Tupel: (mount_point, fstype, options, source)
let file = match File::open("/proc/mounts") {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let reader = BufReader::new(file);
let mut result = Vec::new();
for line in reader.lines().flatten() {
// Format /proc/mounts:
// fs_spec fs_file fs_vfstype fs_mntops fs_freq fs_passno
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 6 {
continue;
}
let fs_spec = parts[0].to_string();
let fs_file = parts[1].to_string();
let fs_vfstype = parts[2].to_string();
let fs_mntops = parts[3].to_string();
result.push((fs_file, fs_vfstype, fs_mntops, fs_spec));
}
result
}
#[cfg(not(target_os = "windows"))]
fn normalize_mount_point(p: &str) -> String {
// Entfernt redundante Slashes am Ende (außer bei "/") und canonicalized soweit möglich.
if p == "/" {
return "/".to_string();
}
let trimmed = p.trim_end_matches('/');
// Versuche, realpath zu bilden, falle sonst auf trimmed zurück
let path = Path::new(trimmed);
path.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| trimmed.to_string())
}
#[cfg(not(target_os = "windows"))]
fn type_matches(expected: &str, actual_fstype: &str, options: &str) -> bool {
// Normalisiere erwartete Typfamilien
match expected {
// NFS kann als nfs oder nfs4 erscheinen
"nfs" | "nfs4" => actual_fstype == "nfs" || actual_fstype == "nfs4",
// davfs/webdav erscheint häufig als fuse.davfs (oder fuse mit helper=davfs)
"webdav" | "davfs" | "davfs2" => {
actual_fstype == "fuse.davfs"
|| actual_fstype == "davfs"
|| (actual_fstype == "fuse" && options.contains("helper=davfs"))
}
// CIFS/Samba Alias
"cifs" | "smb" | "smb3" => actual_fstype == "cifs" || actual_fstype == "smb3",
// Fallback: exakter Vergleich
other => other == actual_fstype,
}
}
+14
View File
@@ -0,0 +1,14 @@
//! smart-mount: bindet lokale/Cloud-Laufwerkspaare (WebDAV/SMB/NFS) dynamisch ein und
//! schaltet automatisch zwischen LAN und Cloud um.
pub mod config;
pub mod crypto;
pub mod db;
pub mod doctor;
pub mod error;
pub mod mount;
pub mod network;
pub mod reconcile;
pub mod systemd;
pub use error::{Error, Result};
-241
View File
@@ -1,241 +0,0 @@
//! Einfaches, threadsicheres Logging-Modul.
//!
//! Merkmale:
//! - Ausgabe in Terminal (stdout/stderr) und zusätzlich in eine Logdatei im temporären Verzeichnis.
//! - Nachrichtenvorlage: `[DD.MM.YYYY HH:MM:SS.mmm][LEVEL][TAG]: <Text>` mit Emoji-Präfix (🛑/⚠️/️/🚧).
//! - Der aktuell verwendete Schweregradfilter ist statisch (`LOG_LEVEL`) und wird zur Laufzeit nicht geändert.
//! - Terminal-Erkennung und Dateihandle werden lazily initialisiert und zwischengespeichert (`OnceLock`).
//!
//! Hinweise:
//! - Bei einem leeren `tag` wird ein leerer Tag-Abschnitt erzeugt.
//! - Die Logdatei wird unterhalb eines prozessspezifischen Ordners im System-Temp-Verzeichnis abgelegt und nach Datum
//! benannt (z. B. `log-2025-08-18.log`). Es wird im Append-Modus geschrieben.
//!
//! Beispiel (ohne Ausführung in Doctests):
//! ```rust,no_run
//! use crate::log::{log, LogLevel};
//!
//! log("startup", "Dienst wird initialisiert …", LogLevel::Info);
//! log("db", "Verbindung unterbrochen!", LogLevel::Warn);
//! log("core", "Unerwarteter Fehler", LogLevel::Error);
//! ```
use std::env;
use std::fmt::{Display, Formatter};
use std::fs::{create_dir_all, File, OpenOptions};
use std::io::{stdout, IsTerminal, Write};
use std::sync::{Mutex, OnceLock};
use crate::config;
use crate::program;
use time::{macros::format_description, OffsetDateTime};
/// Schweregrade für Logeinträge in aufsteigender Detailtiefe.
///
/// Die Reihenfolge bestimmt den Filter: Nur Einträge mit `log_level <= LOG_LEVEL` werden ausgegeben.
///
/// Anzeige (Display):
/// - `Error` -> `ERROR`
/// - `Warn` -> `WARN`
/// - `Info` -> `INFO`
/// - `Debug` -> `DEBUG`
#[derive(PartialEq, Eq, PartialOrd, Ord, Copy, Clone)]
pub enum LogLevel {
/// Kritische Fehler, gehen zusätzlich auf `stderr`.
Error = 1,
/// Wichtige Warnungen über potenzielle Probleme.
Warn = 2,
/// Allgemeine Betriebsinformationen.
Info = 3,
/// Ausführliche Diagnoseausgaben für die Entwicklung.
Debug = 4,
}
impl Display for LogLevel {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
LogLevel::Error => write!(f, "ERROR"),
LogLevel::Warn => write!(f, "WARN"),
LogLevel::Info => write!(f, "INFO"),
LogLevel::Debug => write!(f, "DEBUG"),
}
}
}
impl TryFrom<String> for LogLevel {
type Error = LogLevel;
fn try_from(value: String) -> Result<Self, Self> {
match value.to_lowercase().as_str() {
"error" => Ok(LogLevel::Error),
"warn" => Ok(LogLevel::Warn),
"info" => Ok(LogLevel::Info),
"debug" => Ok(LogLevel::Debug),
_ => Err(LogLevel::Info)
}
}
}
/// Zwischenspeicher für die einmalig ermittelte Terminal-Fähigkeit von `stdout`.
static IS_TERMINAL: OnceLock<bool> = OnceLock::new();
/// Globaler Schweregradfilter für Ausgabe.
static LOG_LEVEL: OnceLock<LogLevel> = OnceLock::new();
/// Lazy-initialisiertes Handle zur Logdatei; kann `None` sein, falls das Öffnen fehlschlug.
static LOG_FILE: OnceLock<Mutex<Option<File>>> = OnceLock::new();
/// Protokolliert eine Nachricht abhängig vom angegebenen Schweregrad.
///
/// Verhalten:
/// - Wenn `log_level` größer als der globale Filter ist, wird nichts ausgegeben.
/// - Bei Terminalausgabe gehen `Error`-Meldungen nach `stderr`, alle anderen nach `stdout`.
/// - Zusätzlich wird in eine tägliche Logdatei im Temp-Verzeichnis geschrieben (wenn erfolgreich geöffnet).
///
/// Parameter:
/// - `tag`: Kurzer Kontext (z. B. Modulname).
/// - `message`: Die eigentliche Nachricht (eine Zeile).
/// - `log_level`: Schweregrad der Nachricht.
///
/// Thread-Sicherheit:
/// - Dateischreibzugriffe sind über `Mutex` serialisiert.
///
/// Beispiel:
/// ```rust,no_run
/// # use crate::log::{log, LogLevel};
/// log("http", "Server gestartet auf Port 8080", LogLevel::Info);
/// ```
pub fn log(tag: &str, message: &str, log_level: LogLevel) {
if log_level <= get_log_level() {
let message: String = format_message(tag, message, &log_level);
if is_terminal() {
if log_level == LogLevel::Error {
eprintln!("{}", message)
} else {
println!("{}", message)
}
}
let file_lock = get_or_init_log_file();
if let Ok(mut guard) = file_lock.lock() {
if let Some(f) = guard.as_mut() {
let _ = writeln!(f, "{}", message);
let _ = f.flush();
}
}
}
}
/// Liefert das einmalig initialisierte Log-Level für die Filterung.
///
/// Ermittelt das konfigurierte Log-Level aus der Config-Datei.
/// Wenn ungültig oder nicht vorhanden, wird LogLevel::Info als Fallback verwendet.
///
/// Rückgabe:
/// - `LogLevel`: Das zu verwendende Log-Level als enum-Wert.
fn get_log_level() -> LogLevel {
*LOG_LEVEL.get_or_init(|| {
let log_level = &config::get_config().general.log_level;
LogLevel::try_from(log_level.to_string()).unwrap_or(LogLevel::Info)
}
)
}
/// Ermittelt einmalig, ob `stdout` ein Terminal ist, und cached das Ergebnis.
///
/// Rückgabe:
/// - `true`, wenn `stdout` ein TTY/Terminal ist.
/// - `false` andernfalls.
fn is_terminal() -> bool {
*IS_TERMINAL.get_or_init(|| stdout().is_terminal())
}
/// Formatiert eine Lognachricht mit Zeitstempel, Level, Tag und Emoji-Präfix.
///
/// Format:
/// - Datum/Zeit lokal (Fallback: UTC) im Format `DD.MM.YYYY HH:MM:SS.mmm`.
/// - Level als Text (`ERROR`, `WARN`, `INFO`, `DEBUG`).
/// - Tag in eckigen Klammern; bei leerem Tag entsteht ein leeres `[]`.
/// - Emoji-Präfix pro Level: 🛑/⚠️/️/🚧.
///
/// Beispielausgabe:
/// - `️[18.08.2025 14:23:45.012][INFO][init]: Fertig`
///
/// Hinweis:
/// - Diese Funktion formatiert nur; sie führt keinen I/O aus.
fn format_message(tag: &str, message: &str, log_level: &LogLevel) -> String {
let mut prefix: String = String::new();
let now_local: OffsetDateTime =
OffsetDateTime::now_local().unwrap_or(OffsetDateTime::now_utc());
let fmt =
format_description!("[day].[month].[year] [hour]:[minute]:[second].[subsecond digits:3]");
if tag != "" {
prefix = format!("[{}]", tag);
}
let message = format!(
"[{}][{}][{}]: {}",
now_local.format(fmt).unwrap(),
log_level.to_string(),
prefix,
message
);
match log_level {
&LogLevel::Error => format!("{}{}", "🛑", message),
&LogLevel::Warn => format!("{}{}", "⚠️", message),
&LogLevel::Info => format!("{}{}", "", message),
&LogLevel::Debug => format!("{}{}", "🚧", message),
}
}
/// Initialisiert das Logdatei-Handle beim ersten Aufruf und liefert eine Referenz darauf.
///
/// Rückgabe:
/// - `&'static Mutex<Option<File>>`: Das Mutex schützt den optionalen Dateihandler.
/// `None` bedeutet, dass das Öffnen fehlgeschlagen ist (z. B. fehlende Rechte).
fn get_or_init_log_file() -> &'static Mutex<Option<File>> {
LOG_FILE.get_or_init(|| {
let file = open_log_file().ok();
Mutex::new(file)
})
}
/// Öffnet (und erstellt bei Bedarf) die tagesbasierte Logdatei im Temp-Verzeichnis.
///
/// Pfadaufbau:
/// - Basis: `std::env::temp_dir()`
/// - Unterordner: `<programmname>-<konstante-uuid>`
/// - Datei: `log-YYYY-MM-DD.log` im Append-Modus
///
/// Rückgabe:
/// - `Ok(File)`, wenn der Ordner erstellt/gefunden und die Datei geöffnet/angelegt werden konnte.
/// - `Err(std::io::Error)`, wenn ein I/O-Fehler auftrat.
///
/// Fehler:
/// - Gibt I/O-Fehler unverändert weiter (z. B. beim Erstellen des Ordners oder Öffnen der Datei).
fn open_log_file() -> std::io::Result<File> {
let program = program::program_name();
let rand = "13692bbf-a93b-43e9-9cc6-f05f94a8cfb6";
let mut dir = env::temp_dir();
dir.push(format!("{}-{}", program, rand));
create_dir_all(&dir)?;
let today_fmt = format_description!("[year]-[month]-[day]");
let date_str = OffsetDateTime::now_local()
.unwrap_or(OffsetDateTime::now_utc())
.format(today_fmt)
.unwrap_or_else(|_| "0000-00-00".to_string());
let file_name = format!("log-{}.log", date_str);
dir.push(file_name);
OpenOptions::new()
.create(true)
.append(true)
.open(dir)
}
+23 -324
View File
@@ -1,335 +1,34 @@
use crate::config::{Storage, get_config, modify_config};
use crate::filesystem::credentials::save_credentials_webdav;
use crate::filesystem::mount::{mount, unmount};
use crate::filesystem::mounted::{is_mounted, is_mounted_as};
use crate::log::LogLevel;
use crate::log::log;
use crate::network::network_interface::{get_active_network_interface, get_interface_ip_address};
use crate::network::utils::{
get_ip_from_mac, get_mac_from_ip, get_network_address, is_reachable, wake_on_land,
};
use crate::sudo::{is_run_as_root, run_as_root};
use std::process::exit;
use std::thread;
use std::thread::{JoinHandle, sleep};
use std::time::Duration;
mod cli;
mod config;
mod filesystem;
mod log;
mod network;
mod program;
mod sudo;
use std::process::ExitCode;
fn main() {
log(
"main",
"========== PROGRAM START ==========",
LogLevel::Info,
);
use clap::Parser;
if !is_run_as_root() {
log(
"main",
"Program is not run as root. Trying to run as root...",
LogLevel::Warn,
);
run_as_root();
}
#[tokio::main]
async fn main() -> ExitCode {
smart_mount::config::init();
let network_interface: String;
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));
let mut count: i32 = 0;
loop {
let interface_str: String = get_active_network_interface().unwrap().trim().to_string();
if !interface_str.is_empty() {
network_interface = interface_str;
break;
} else if count >= 10 {
log(
"main",
"Couldn't find active network card, exiting.",
LogLevel::Error,
);
exit(1);
}
log(
"main",
"No active network card found, waiting 1 second.",
LogLevel::Warn,
);
count = count + 1;
sleep(Duration::from_secs(1));
}
log(
"main",
&*format!("Active network interface found: {}", network_interface),
LogLevel::Info,
);
let interface_address = get_interface_ip_address(network_interface.as_str())
.unwrap()
.trim()
.to_string();
log(
"main",
&*format!("Interface address: {}", interface_address),
LogLevel::Info,
);
let network_address = get_network_address(interface_address.as_str())
.unwrap()
.trim()
.to_string();
log(
"main",
&*format!("Network address: {}", network_address),
LogLevel::Info,
);
count = 0;
loop {
if is_reachable(network_address.as_str()) {
break;
} else if count >= 10 {
log(
"main",
"Couldn't reach network address, exiting.",
LogLevel::Error,
);
exit(1);
}
log(
"main",
"Network address not reachable, waiting 1 second.",
LogLevel::Warn,
);
count = count + 1;
sleep(Duration::from_secs(1));
}
log("main", "Network address is reachable.", LogLevel::Info);
let handle_local: JoinHandle<()> = thread::spawn(move || mount_local(network_address));
let handle_remote: JoinHandle<()> = thread::spawn(mount_remote);
handle_local.join().unwrap();
handle_remote.join().unwrap();
log("main", "========== PROGRAM END ==========", LogLevel::Info);
}
fn mount_local(network_address: String) {
log(
"main",
"Trying to mount filesystem locally...",
LogLevel::Info,
);
let mount_point: &str = get_config().general.mount_point.as_str();
let mac_address: &str = get_config().local.device_mac.as_str();
let mount_type: &str = get_config().local.mount_type.as_str();
let mut device_address: Option<String> = None;
if get_config().storage.is_some() {
device_address = Some(get_config().storage.clone().unwrap().device_ip);
if is_reachable(&device_address.clone().unwrap()) {
log(
"main",
format!(
"Searching mac for device address {}.",
device_address.clone().unwrap()
)
.as_str(),
LogLevel::Info,
);
let mac_of_ip =
get_mac_from_ip(device_address.clone().unwrap().as_str()).unwrap_or("".to_string());
log(
"main",
format!(
"Found mac {} for device address {}.",
mac_of_ip,
device_address.clone().unwrap()
)
.as_str(),
LogLevel::Info,
);
if mac_of_ip == mac_address {
log(
"main",
format!(
"Found device mac {} on saved ip {}.",
mac_address,
device_address.clone().unwrap()
)
.as_str(),
LogLevel::Info,
);
} else {
log(
"main",
format!(
"Device mac {} is not the same as {} of ip {}.",
mac_address,
mac_of_ip,
device_address.clone().unwrap()
)
.as_str(),
LogLevel::Warn,
);
device_address = None;
}
} else {
log(
"main",
format!(
"Device address {} is not reachable.",
device_address.clone().unwrap()
)
.as_str(),
LogLevel::Warn,
);
device_address = None;
}
}
let mut count: i32 = 0;
if device_address.is_none() {
loop {
device_address = get_ip_from_mac(mac_address, network_address.as_str());
if device_address.is_some() || count >= 10 {
modify_config(|config| {
let storage = config.storage.get_or_insert(Storage::default());
storage.device_ip = device_address.clone().unwrap();
});
break;
}
log(
"main",
"Couldn't find MAC adress in local network, sending awake call...",
LogLevel::Info,
);
wake_on_land(mac_address);
log("main", "Waiting 30 seconds...", LogLevel::Info);
count = count + 1;
sleep(Duration::from_secs(30));
}
}
if device_address.is_none() {
log(
"main",
"Couldn't find device address for MAC address.",
LogLevel::Warn,
);
if is_mounted_as(mount_point, mount_type) {
log(
"main",
"Filesystem is mounted locally. Unmounting...",
LogLevel::Info,
);
unmount(mount_point);
}
mount_remote();
} else {
let dev_ip: String = device_address.unwrap();
log(
"main",
"Found MAC address in local network.",
LogLevel::Info,
);
if !is_mounted_as(mount_point, mount_type) {
if is_mounted(mount_point) {
log(
"main",
"Filesystem is mounted. Unmounting...",
LogLevel::Info,
);
unmount(mount_point);
}
log("main", "Mounting local filesystem...", LogLevel::Info);
mount(
mount_point,
&*format!("{}:{}", dev_ip, get_config().local.mount_path),
mount_type,
);
} else {
log(
"main",
"Filesystem is already mounted locally. Doing nothing.",
LogLevel::Info,
);
let cli = cli::Cli::parse();
match cli::dispatch(cli).await {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("Error: {e:#}");
ExitCode::FAILURE
}
}
}
fn mount_remote() {
let mount_point: &str = get_config().general.mount_point.as_str();
let mount_type: &str = &get_config().remote.mount_type.as_str();
let mut could_reach: bool;
let mut count: i32 = 0;
loop {
could_reach = is_reachable("https://ping.creative-dragonslayer.de");
if could_reach || count >= 10 {
break;
}
log(
"main",
"Couldn't reach remote server, waiting 1 second.",
LogLevel::Warn,
);
count = count + 1;
sleep(Duration::from_secs(1));
}
if could_reach {
log("main", "Remote server reachable.", LogLevel::Info);
if mount_type == "davfs" || mount_type == "webdav" {
save_credentials_webdav();
}
if is_mounted_as(mount_point, get_config().local.mount_type.as_str())
|| is_mounted_as(mount_point, mount_type)
{
log(
"main",
"Filesystem is already mounted. Doing nothing.",
LogLevel::Info,
);
} else {
if is_mounted(mount_point) {
log(
"main",
"Filesystem is already mounted. Unmounting...",
LogLevel::Info,
);
unmount(mount_point);
}
log("main", "Mounting remote filesystem...", LogLevel::Info);
mount(mount_point, &*get_config().remote.mount_path, mount_type);
}
} else {
log("main", "Remote server not reachable.", LogLevel::Warn);
fn parse_log_level(level: &str) -> logger_ctdra::LogLevel {
match level.to_lowercase().as_str() {
"error" => logger_ctdra::LogLevel::Error,
"warn" => logger_ctdra::LogLevel::Warn,
"debug" => logger_ctdra::LogLevel::Debug,
_ => logger_ctdra::LogLevel::Info,
}
}
+110
View File
@@ -0,0 +1,110 @@
//! Pro-Paar-Dateisperre, damit ein manueller `mount --name X` nicht mit einem gleichzeitig
//! laufenden `watch` für dasselbe Paar kollidiert.
//!
//! **Warum eine Datei-basierte Sperre (`flock(2)`) statt eines simplen `Mutex`:** smart-mount
//! ist ein One-Shot-CLI (siehe [`crate::reconcile`]-Moduldoku) - jeder Aufruf ist ein eigener,
//! kurzlebiger Prozess. Ein rein prozessinterner `Mutex<HashMap<...>>` schützt daher NICHT vor
//! zwei gleichzeitig laufenden `smart-mount`-Prozessen (z. B. ein manuelles `unmount --name X`
//! während ein systemd-Timer-`watch` läuft): beide bekämen ihre eigene, leere Registry und
//! würden sich nie gegenseitig blockieren. Die Sperre muss daher außerhalb des Prozess-Speichers
//! liegen - hier über eine `flock`-Datei pro Paar (`std::fs::File::lock()`, seit Rust 1.89
//! Teil der Standardbibliothek, keine zusätzliche Abhängigkeit nötig).
//!
//! Ersetzt den globalen `RwLock`+`Mutex` aus dem alten `src/filesystem/mount.rs` (v0.2.0):
//! dort war die Sperre prozessweit global, hier ist sie pro Laufwerkspaar - mehrere Paare
//! können also parallel gemountet werden, ohne sich gegenseitig zu blockieren.
use std::fs::{File, OpenOptions};
use std::path::PathBuf;
use crate::error::{Error, Result};
/// Hält die Sperrdatei offen (und damit die `flock`-Sperre über den zugehörigen
/// 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 {
_file: File,
}
/// 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
/// System-Temp-Verzeichnis, falls `$XDG_RUNTIME_DIR` nicht gesetzt ist (z. B. ohne aktive
/// Login-Session).
fn lock_dir() -> PathBuf {
if sudo_ctdra::is_run_as_root() {
PathBuf::from("/run/smart-mount/locks")
} else {
std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir)
.join("smart-mount-locks")
}
}
fn acquire_blocking(pair_id: &str) -> Result<PairLock> {
let dir = lock_dir();
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
let path = dir.join(format!("{pair_id}.lock"));
// `truncate(false)`: der Dateiinhalt ist irrelevant, nur der Deskriptor/die `flock`-Sperre
// darauf zählt - ein Zurücksetzen auf leer bei jedem Aufruf wäre unnötig.
let file = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&path)
.map_err(|e| Error::io(&path, e))?;
// 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.
file.lock().map_err(|e| Error::io(&path, e))?;
Ok(PairLock { _file: file })
}
/// Sperrt ein Laufwerkspaar prozessübergreifend für die Dauer des zurückgegebenen Guards.
pub async fn acquire(pair_id: &str) -> Result<PairLock> {
let pair_id = pair_id.to_string();
tokio::task::spawn_blocking(move || acquire_blocking(&pair_id))
.await
.map_err(|e| Error::Other(format!("lock task panicked: {e}")))?
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn acquire_returns_a_guard_and_releases_on_drop() {
let id = format!("test-lock-{}", uuid::Uuid::new_v4());
{
let _guard = acquire(&id).await.expect("first acquire");
}
// Sollte nicht blockieren: der obige Guard wurde bereits gedroppt.
let _guard2 = acquire(&id).await.expect("second acquire after drop");
}
#[tokio::test]
async fn a_second_concurrent_acquire_waits_for_the_first_to_be_dropped() {
let id = format!("test-lock-{}", uuid::Uuid::new_v4());
let guard = acquire(&id).await.expect("first acquire");
let id2 = id.clone();
let handle = tokio::spawn(async move { acquire(&id2).await });
// Kurz warten, damit der spawnte Task realistischerweise Zeit hatte, in `acquire` zu
// blockieren, bevor die erste Sperre freigegeben wird.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(
!handle.is_finished(),
"second acquire must block while the first guard is held"
);
drop(guard);
handle
.await
.expect("task")
.expect("second acquire after release");
}
}
+258
View File
@@ -0,0 +1,258 @@
//! Mount-Backends für WebDAV/SMB/NFS mit dynamischem Dispatch.
//!
//! "Dynamisch nachladen" bedeutet hier: Trait-Object-Dispatch je nach [`MountKind`], und
//! jedes Backend prüft sein benötigtes System-Binary (`mount.davfs`/`mount.cifs`/`mount.nfs`)
//! erst, wenn es tatsächlich benutzt wird ([`MountBackend::check_available`]) - ein reiner
//! WebDAV-Nutzer wird also nicht gezwungen, `cifs-utils`/`nfs-common` zu installieren.
pub mod lock;
pub mod nfs;
pub mod smb;
pub mod state;
pub mod target;
pub mod webdav;
use std::path::PathBuf;
use crate::config::{DrivePair, GlobalSettings, MountKind};
use crate::db::credentials::{Credential, Side};
use crate::error::Result;
/// Alle Informationen, die ein Backend braucht, um eine Seite eines Laufwerkspaars ein-
/// bzw. auszuhängen. Der Mount-Aufruf ist immer `mount -t <type> <source> <target> -o <opts>`
/// als root (smart-mount läuft ausschließlich als root/System-Dienst, siehe [`crate::systemd`]).
pub struct MountTarget {
pub pair_id: String,
/// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile
/// Dateinamen für Credentials-/Secrets-Dateien.
pub side: Side,
pub mount_point: PathBuf,
/// Vollständiger Quellstring, z. B. `//server/share`, `https://host/path`, `server:/export`.
pub source: String,
/// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`) - mit Kommas
/// verbindbar für `mount -o`.
pub options: Vec<String>,
/// Für Paare mit gesetztem `owner_user`: der Linux-Benutzername, dem Zugangsdaten-/
/// Secrets-Dateien gehören sollen.
pub owner_user: Option<String>,
}
/// Gemeinsame Schnittstelle für WebDAV/SMB/NFS-Backends.
pub trait MountBackend: Send + Sync {
fn name(&self) -> &'static str;
/// Prüft, ob das für dieses Backend nötige System-Binary vorhanden ist. Liefert bei
/// Fehlen einen Fehler mit dem Namen des nachzuinstallierenden Pakets.
fn check_available(&self) -> Result<()>;
/// Schreibt/aktualisiert alles, was der eigentliche `mount`-Aufruf voraussetzt
/// (Credentials-/Secrets-Dateien, Config-Anpassungen wie davfs2s `gui_optimize`).
fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()>;
fn mount(&self, target: &MountTarget) -> Result<()>;
fn unmount(&self, target: &MountTarget) -> Result<()>;
}
/// Wählt das passende Backend für einen [`MountKind`] (Trait-Object-Dispatch).
pub fn backend_for(kind: MountKind) -> Box<dyn MountBackend> {
match kind {
MountKind::WebDav => Box::new(webdav::WebDavBackend),
MountKind::Smb => Box::new(smb::SmbBackend),
MountKind::Nfs => Box::new(nfs::NfsBackend),
}
}
/// Timeout in Sekunden für `mount`/`umount`-Subprozesse (siehe [`run_tolerating_already_done`]).
/// Ein nicht mehr erreichbarer Server darf `smart-mount watch` niemals unbegrenzt blockieren -
/// z. B. ist ein `umount` auf einem davfs2-Mount laut davfs2-eigener FAQ absichtlich so lange
/// blockierend, bis alle zwischengespeicherten Daten geschrieben sind, was bei einem dauerhaft
/// unerreichbaren Server sonst nie zurückkehren würde.
pub(crate) const MOUNT_TIMEOUT_SECS: u64 = 30;
/// Führt einen `mount`/`umount`-Subprozess mit einem externen Timeout aus (über das
/// coreutils-Tool `timeout`, auf jedem Linux-System vorhanden) und toleriert "bereits
/// eingebunden"/"busy"/"nicht eingebunden" als No-op statt als Fehler (portiert aus dem alten
/// `src/filesystem/mount.rs`, v0.2.0).
///
/// `tolerate_timeout`: bei `umount`-Aufrufen (`true`) wird ein durch den Timeout abgebrochener
/// Versuch nur geloggt und als Erfolg gewertet - der nächste `watch`-Durchlauf versucht es
/// erneut (idempotent, `umount` toleriert bereits "nicht eingebunden"). Bei `mount`-Aufrufen
/// (`false`) ist ein Timeout ein echter Fehlschlag, da dabei nichts erfolgreich eingebunden
/// wurde.
pub(crate) fn run_tolerating_already_done(
cmd: std::process::Command,
context: &str,
tolerate_timeout: bool,
) -> Result<()> {
run_tolerating_already_done_with_timeout(cmd, context, tolerate_timeout, MOUNT_TIMEOUT_SECS)
}
fn run_tolerating_already_done_with_timeout(
cmd: std::process::Command,
context: &str,
tolerate_timeout: bool,
timeout_secs: u64,
) -> Result<()> {
let output =
run_with_timeout(cmd, timeout_secs).map_err(|e| crate::error::Error::MountFailed {
context: context.to_string(),
stderr: e.to_string(),
})?;
if output.status.success() {
return Ok(());
}
// GNU coreutils `timeout` beendet sich mit Exit-Code 124, wenn es den Kindprozess wegen
// Zeitüberschreitung abbrechen musste (dokumentiertes Verhalten von `timeout(1)`).
if tolerate_timeout && output.status.code() == Some(124) {
logger_ctdra::warn(
"mount",
&format!(
"{context}: aborted after {timeout_secs}s (server likely unreachable) - will be retried on the next run"
),
);
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).to_lowercase();
if stderr.contains("already mounted")
|| stderr.contains("busy")
|| stderr.contains("not mounted")
{
return Ok(());
}
Err(crate::error::Error::MountFailed {
context: context.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
/// Führt `cmd` über das coreutils-Tool `timeout` aus, damit ein hängender `mount`/`umount`
/// den aufrufenden `smart-mount`-Prozess nie unbegrenzt blockiert.
fn run_with_timeout(
cmd: std::process::Command,
timeout_secs: u64,
) -> std::io::Result<std::process::Output> {
let program = cmd.get_program().to_os_string();
let args: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect();
std::process::Command::new("timeout")
.arg(timeout_secs.to_string())
.arg(program)
.args(args)
.output()
}
/// Prüft per `which`, ob ein Binary im `PATH` auffindbar ist.
pub(crate) fn binary_available(binary: &str) -> bool {
std::process::Command::new("which")
.arg(binary)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Entfernt (best-effort) die für `pair` auf beiden Seiten hinterlegten Klartext-
/// Zugangsdaten, die `mount -t davfs`/`mount -t cifs` benötigen (NICHT die verschlüsselten
/// DB-Zeilen - die werden separat über `CredentialStore::delete` entfernt). Für
/// `smart-mount drive remove`, damit kein Passwort für ein gelöschtes Paar auf der Platte
/// zurückbleibt. Fehler werden nur geloggt, nie propagiert: eine nicht perfekt aufräumbare
/// Restdatei darf das Entfernen des Paars nicht blockieren.
pub fn cleanup_credentials(pair: &DrivePair, settings: &GlobalSettings) {
cleanup_side_credentials(pair, settings, Side::Local, pair.local.kind);
cleanup_side_credentials(pair, settings, Side::Cloud, pair.cloud.kind);
}
fn cleanup_side_credentials(
pair: &DrivePair,
settings: &GlobalSettings,
side: Side,
kind: MountKind,
) {
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()
),
);
}
}
MountKind::WebDav => {
let source = match side {
Side::Local => match target::local_source(&pair.local, settings) {
Ok(s) => s,
Err(e) => {
logger_ctdra::warn(
"mount",
&format!(
"Could not resolve local source for pair '{}' to clean up its \
davfs2 secrets entry: {e} - a stale plaintext credential may \
remain on disk",
pair.id
),
);
return;
}
},
Side::Cloud => target::cloud_source(&pair.cloud),
};
let path = webdav::davfs2_secrets_path();
if let Err(e) = webdav::remove_secrets_entry(&path, &source) {
logger_ctdra::warn(
"mount",
&format!("Could not remove secrets entry for '{source}': {e}"),
);
}
}
MountKind::Nfs => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
#[test]
fn run_with_timeout_kills_a_hanging_command_and_reports_exit_124() {
let mut cmd = Command::new("sleep");
cmd.arg("5");
let output = run_with_timeout(cmd, 1).expect("timeout wrapper itself should run");
// GNU coreutils `timeout` exits 124 when it had to kill the child - this is the
// signal `run_tolerating_already_done_with_timeout` checks for below.
assert_eq!(output.status.code(), Some(124));
}
#[test]
fn run_with_timeout_returns_promptly_for_a_command_that_finishes_in_time() {
let cmd = Command::new("true");
let started = std::time::Instant::now();
let output = run_with_timeout(cmd, 10).expect("run");
assert!(output.status.success());
assert!(started.elapsed() < std::time::Duration::from_secs(5));
}
#[test]
fn timeout_is_tolerated_for_unmount_and_returns_ok() {
let mut cmd = Command::new("sleep");
cmd.arg("5");
let result = run_tolerating_already_done_with_timeout(cmd, "test umount", true, 1);
assert!(result.is_ok());
}
#[test]
fn timeout_is_a_hard_error_for_mount() {
let mut cmd = Command::new("sleep");
cmd.arg("5");
let result = run_tolerating_already_done_with_timeout(cmd, "test mount", false, 1);
assert!(result.is_err());
}
}
+50
View File
@@ -0,0 +1,50 @@
//! NFS-Backend (`mount.nfs`). In der Regel keine Zugangsdaten nötig - Autorisierung erfolgt
//! serverseitig über Export-ACLs (`sec=sys`), optional `sec=krb5*` über `extra_options`.
use std::process::Command;
use crate::db::credentials::Credential;
use crate::error::{Error, Result};
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
pub struct NfsBackend;
impl MountBackend for NfsBackend {
fn name(&self) -> &'static str {
"nfs"
}
fn check_available(&self) -> Result<()> {
if binary_available("mount.nfs") || binary_available("mount.nfs4") {
Ok(())
} else {
Err(Error::BackendUnavailable {
backend: "nfs",
reason: "'mount.nfs'/'mount.nfs4' not found - install package 'nfs-common' (Debian/Ubuntu) or 'nfs-utils' (Fedora/Arch)".to_string(),
})
}
}
fn prepare(&self, _target: &MountTarget, _cred: Option<&Credential>) -> Result<()> {
// Normalerweise kein Vorbereitungsschritt nötig (kein Credentials-File).
Ok(())
}
fn mount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("mount");
cmd.arg("-t")
.arg("nfs")
.arg(&target.source)
.arg(&target.mount_point);
if !target.options.is_empty() {
cmd.arg("-o").arg(target.options.join(","));
}
run_tolerating_already_done(cmd, "nfs mount", false)
}
fn unmount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("umount");
cmd.arg(&target.mount_point);
run_tolerating_already_done(cmd, "nfs umount", true)
}
}
+191
View File
@@ -0,0 +1,191 @@
//! SMB/CIFS-Backend (`mount.cifs`).
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::process::Command;
use crate::db::credentials::Credential;
use crate::error::{Error, Result};
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
pub struct SmbBackend;
impl MountBackend for SmbBackend {
fn name(&self) -> &'static str {
"cifs"
}
fn check_available(&self) -> Result<()> {
if binary_available("mount.cifs") {
Ok(())
} else {
Err(Error::BackendUnavailable {
backend: "cifs",
reason: "'mount.cifs' not found - install package 'cifs-utils'".to_string(),
})
}
}
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)?;
}
Ok(())
}
fn mount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("mount");
cmd.arg("-t")
.arg("cifs")
.arg(&target.source)
.arg(&target.mount_point);
let mut opts = target.options.clone();
opts.push(format!(
"credentials={}",
credentials_path(&target.pair_id, target.side).display()
));
cmd.arg("-o").arg(opts.join(","));
run_tolerating_already_done(cmd, "cifs mount", false)
}
fn unmount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("umount");
cmd.arg(&target.mount_point);
run_tolerating_already_done(cmd, "cifs umount", true)
}
}
/// Stabiler Pfad (nicht ein Tempfile!) - `prepare()` schreibt hierhin, `mount()` referenziert
/// denselben Pfad über `-o credentials=...`.
pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf {
let base = config_ctdra::get_config_path()
.parent()
.map(|d| d.join("creds"))
.unwrap_or_else(|| PathBuf::from("creds"));
base.join(format!("{pair_id}-{}.cred", side.as_str()))
}
/// `mount.cifs`s Credentials-Datei ist zeilenbasiert (`key=value`) - ein eingebettetes
/// Newline in einem Feld würde eine zusätzliche, nicht vorgesehene `key=value`-Zeile
/// einschleusen (z. B. ein überraschendes `domain=...` oder ein zweites `password=`, das das
/// eigentliche überschreibt). Ein leeres Passwort ist ebenfalls kein sinnvoller Wert.
fn validate_credential_field(name: &str, value: &str) -> Result<()> {
if value.contains(['\n', '\r']) {
return Err(Error::Other(format!(
"cifs credential field '{name}' must not contain a newline"
)));
}
Ok(())
}
fn write_credentials_file(path: &PathBuf, cred: &Credential) -> Result<()> {
if let Some(username) = &cred.username {
validate_credential_field("username", username)?;
}
validate_credential_field("password", &cred.password)?;
if cred.password.is_empty() {
return Err(Error::Other(
"cifs credential has an empty password".to_string(),
));
}
if let Some(domain) = &cred.domain {
validate_credential_field("domain", domain)?;
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
}
let mut contents = String::new();
if let Some(username) = &cred.username {
contents.push_str(&format!("username={username}\n"));
}
contents.push_str(&format!("password={}\n", cred.password));
if let Some(domain) = &cred.domain {
contents.push_str(&format!("domain={domain}\n"));
}
#[cfg(unix)]
let mut opts = OpenOptions::new();
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
#[cfg(not(unix))]
let mut opts = OpenOptions::new();
let mut file = opts
.write(true)
.create(true)
.truncate(true)
.open(path)
.map_err(|e| Error::io(path, e))?;
file.write_all(contents.as_bytes())
.map_err(|e| Error::io(path, e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::credentials::Side;
#[test]
fn writes_expected_credentials_format() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("test.cred");
let cred = Credential {
username: Some("nasuser".to_string()),
domain: Some("WORKGROUP".to_string()),
password: "s3cret".to_string(),
};
write_credentials_file(&path, &cred).expect("write");
let contents = std::fs::read_to_string(&path).expect("read");
assert!(contents.contains("username=nasuser"));
assert!(contents.contains("password=s3cret"));
assert!(contents.contains("domain=WORKGROUP"));
}
#[test]
fn rejects_password_with_embedded_newline() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("test.cred");
let cred = Credential {
username: Some("nasuser".to_string()),
domain: None,
password: "s3cret\ndomain=INJECTED".to_string(),
};
assert!(write_credentials_file(&path, &cred).is_err());
assert!(!path.exists());
}
#[test]
fn rejects_empty_password() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("test.cred");
let cred = Credential {
username: Some("nasuser".to_string()),
domain: None,
password: String::new(),
};
assert!(write_credentials_file(&path, &cred).is_err());
}
#[test]
fn credentials_path_differs_per_side() {
let local = credentials_path("pair-1", Side::Local);
let cloud = credentials_path("pair-1", Side::Cloud);
assert_ne!(local, cloud);
}
}
+119
View File
@@ -0,0 +1,119 @@
//! Ermittelt den aktuellen Mount-Zustand eines Mountpoints durch Parsen von
//! `/proc/self/mountinfo` - keine Zusatzabhängigkeit auf `findmnt`.
use std::path::Path;
use crate::error::{Error, Result};
/// Ob `mount_point` aktuell eingebunden ist.
pub fn is_mounted(mount_point: &Path) -> Result<bool> {
Ok(current_source(mount_point)?.is_some())
}
/// Die aktuell an `mount_point` eingebundene Quelle (die Spalte "source" aus mountinfo),
/// oder `None`, falls dort nichts eingebunden ist.
pub fn current_source(mount_point: &Path) -> Result<Option<String>> {
let contents = std::fs::read_to_string("/proc/self/mountinfo")
.map_err(|e| Error::io("/proc/self/mountinfo", e))?;
Ok(parse_mountinfo_source(&contents, mount_point))
}
/// Reine, testbare Parse-Funktion: `mountinfo`-Zeilenformat ist
/// `... <mount_point> <mount_options> <optional fields> - <fs_type> <source> <super_options>`.
/// Bei mehreren Treffern (verschachtelte Mounts) zählt der letzte (= zuletzt gemountete,
/// aktuell sichtbare) Eintrag.
fn parse_mountinfo_source(mountinfo: &str, mount_point: &Path) -> Option<String> {
let target = mount_point.to_string_lossy();
let mut result = None;
for line in mountinfo.lines() {
let fields: Vec<&str> = line.split_whitespace().collect();
// Feld 4 (Index 4) ist der Mountpoint; danach folgen optionale Felder bis zum
// Trenner "-", danach fs_type (Index+1) und source (Index+2).
//
// Der Kernel escaped Leerzeichen/Tab/Newline/Backslash in Pfadfeldern oktal
// (siehe `man 5 proc`, Abschnitt zu mountinfo) - ohne `unescape_octal_field` würde ein
// Mountpoint mit z. B. einem Leerzeichen im Pfad hier nie als "gemountet" erkannt, weil
// `\040` niemals gleich einem echten Leerzeichen ist.
if fields.len() < 5 || unescape_octal_field(fields[4]) != target {
continue;
}
let Some(dash_pos) = fields.iter().position(|&f| f == "-") else {
continue;
};
if let Some(source) = fields.get(dash_pos + 2) {
result = Some(source.to_string());
}
}
result
}
/// Kehrt die oktale Escape-Kodierung um, die der Kernel in `/proc/self/mountinfo` für
/// Leerzeichen (`\040`), Tab (`\011`), Newline (`\012`) und Backslash (`\134`) in Pfadfeldern
/// verwendet (siehe `man 5 proc`).
fn unescape_octal_field(field: &str) -> std::borrow::Cow<'_, str> {
if !field.contains('\\') {
return std::borrow::Cow::Borrowed(field);
}
// Arbeitet auf rohen Bytes statt `char`s: ein Byte einer mehrbytigen UTF-8-Sequenz per
// `as char` in einen eigenständigen `char` umzuwandeln würde nicht-ASCII-Zeichen im Pfad
// (z. B. Umlaute) in mehrere falsche Codepoints zerlegen.
let bytes = field.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\'
&& i + 3 < bytes.len()
&& let Ok(octal) = std::str::from_utf8(&bytes[i + 1..i + 4])
&& let Ok(value) = u8::from_str_radix(octal, 8)
{
out.push(value);
i += 4;
} else {
out.push(bytes[i]);
i += 1;
}
}
std::borrow::Cow::Owned(String::from_utf8_lossy(&out).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn finds_source_for_matching_mount_point() {
let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw\n\
43 36 0:26 / /home/dragon/smart-mount/pair-1 rw,relatime shared:2 - cifs //server/share rw,uid=1000";
let source =
parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1"));
assert_eq!(source.as_deref(), Some("//server/share"));
}
#[test]
fn returns_none_for_unmounted_path() {
let mountinfo = "36 35 98:0 / / rw,relatime shared:1 - ext4 /dev/root rw";
let source =
parse_mountinfo_source(mountinfo, &PathBuf::from("/home/dragon/smart-mount/pair-1"));
assert_eq!(source, None);
}
#[test]
fn matches_a_mount_point_containing_a_space_escaped_by_the_kernel() {
let mountinfo =
"43 36 0:26 / /mnt/my\\040drive rw,relatime shared:2 - cifs //server/share rw";
let source = parse_mountinfo_source(mountinfo, &PathBuf::from("/mnt/my drive"));
assert_eq!(source.as_deref(), Some("//server/share"));
}
#[test]
fn last_matching_entry_wins_for_stacked_mounts() {
let mountinfo = "36 35 98:0 / /mnt/x rw - nfs server:/export rw\n\
37 36 0:26 / /mnt/x rw - davfs https://cloud/dav rw";
let source = parse_mountinfo_source(mountinfo, &PathBuf::from("/mnt/x"));
assert_eq!(source.as_deref(), Some("https://cloud/dav"));
}
}
+529
View File
@@ -0,0 +1,529 @@
//! Baut [`MountTarget`]s für eine Seite eines Paars und verwaltet den symlink-basierten
//! "welche Seite ist aktiv"-Zustand.
//!
//! **Warum ein Symlink statt zwei fstab-Zeilen auf denselben Mountpoint:** `mount(8)`s
//! Berechtigungsprüfung für unprivilegierte `user`-Mounts ist nur für den Fall EINER
//! passenden fstab-Zeile dokumentiert (`man 8 mount`, Abschnitt "Non-superuser mounts",
//! Beispiel `mount /cd`). Der Fall zweier `user,noauto`-Zeilen mit demselben Ziel, aber
//! unterschiedlicher Quelle, ist nirgends spezifiziert - und `mount --fstab <alternative>`
//! verlangt selbst Root, sodass sich das Verhalten nicht einmal gefahrlos in einer Sandbox
//! verifizieren ließ. Um uns nicht auf unspezifiziertes Verhalten zu verlassen, bekommt jede
//! Seite stattdessen ein eigenes, eindeutiges Backing-Verzeichnis mit genau einer fstab-Zeile
//! (der dokumentierte, eindeutige Fall). Der konfigurierte `pair.mount_point` ist kein
//! Mountpoint mehr, sondern ein Symlink, den smart-mount selbst atomar zwischen beiden
//! Backing-Verzeichnissen umschaltet ([`activate_symlink`]). Zu jedem Zeitpunkt ist höchstens
//! kurz während eines Umschaltens (siehe [`crate::reconcile`]) mehr als ein Backing-Verzeichnis
//! gemountet - im Ruhezustand immer nur eines, wie ursprünglich vorgesehen.
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountKind};
use crate::db::credentials::Side;
use crate::error::{Error, Result};
use crate::mount::{self, MountTarget};
use crate::network::address;
/// Eindeutiges Backing-Verzeichnis für eine Seite eines Paars - hier (und nur hier) wird
/// tatsächlich `mount(8)` aufgerufen. Liegt als verstecktes Verzeichnis neben `pair.mount_point`.
pub fn backing_dir(pair: &DrivePair, side: Side) -> PathBuf {
let parent = pair.mount_point.parent().unwrap_or_else(|| Path::new("."));
parent.join(format!(".{}-{}", pair.id, side.as_str()))
}
/// Welches [`MountKind`] eine Seite eines Paars hat.
pub fn side_kind(pair: &DrivePair, side: Side) -> MountKind {
match side {
Side::Local => pair.local.kind,
Side::Cloud => pair.cloud.kind,
}
}
/// Baut ein [`MountTarget`] für eine Seite eines Paars, inkl. Auflösung der lokalen
/// MAC-Adresse zu einer IP (siehe [`address::resolve_ip`]). `target.mount_point` ist das
/// Backing-Verzeichnis (siehe [`backing_dir`]), nicht der sichtbare `pair.mount_point`.
pub fn build_target(
pair: &DrivePair,
settings: &GlobalSettings,
side: Side,
) -> Result<MountTarget> {
build_target_with_cached_local_ip(pair, settings, side, None)
}
/// Wie [`build_target`], nimmt aber optional eine bereits aufgelöste lokale IP entgegen
/// (`Side::Local` mit `LocalAddress::Mac`), um eine zweite `mac2ip`-Subprozess-Auflösung
/// derselben Adresse innerhalb desselben Reconcile-Durchlaufs zu vermeiden (siehe
/// [`crate::reconcile`], das die Erreichbarkeit ohnehin schon per `address::resolve_ip` prüft,
/// bevor es ggf. auf diese Seite umschaltet). Wird für `Side::Cloud` ignoriert.
pub fn build_target_with_cached_local_ip(
pair: &DrivePair,
settings: &GlobalSettings,
side: Side,
cached_local_ip: Option<std::net::Ipv4Addr>,
) -> Result<MountTarget> {
let kind = side_kind(pair, side);
let (source, mut options) = match side {
Side::Local => (
match cached_local_ip {
Some(ip) => format_local_source(&pair.local, ip),
None => local_source(&pair.local, settings)?,
},
parse_options(&pair.local.extra_options),
),
Side::Cloud => (
cloud_source(&pair.cloud),
parse_options(&pair.cloud.extra_options),
),
};
apply_owner_permissions(kind, pair.owner_user.as_deref(), &mut options)?;
apply_nfs_resilience_defaults(kind, &mut options);
Ok(MountTarget {
pair_id: pair.id.clone(),
side,
mount_point: backing_dir(pair, side),
source,
options,
owner_user: pair.owner_user.clone(),
})
}
/// Setzt `uid`/`gid`/`file_mode`/`dir_mode` für Protokolle ohne native Unix-Rechte (CIFS,
/// WebDAV), damit `pair.owner_user` vollen Zugriff auf den Mount hat - inklusive
/// Ausführrechten, damit dort liegende Skripte laufen können (`file_mode`/`dir_mode` steuern
/// bei diesen Protokollen den simulierten `stat()`-Modus jeder Datei/jedes Verzeichnisses
/// einheitlich; `0700` gibt ausschließlich dem Owner volle Rechte). Bereits in
/// `extra_options` explizit gesetzte Werte werden respektiert und nicht überschrieben.
///
/// **NFS ist bewusst ausgenommen:** dort gibt es keine clientseitige `uid=`/`gid=`-Option -
/// welcher lokale Nutzer Zugriff hat, bestimmt der NFS-Server über die tatsächlichen
/// Datei-Eigentümer/-Rechte des Exports (siehe README, Abschnitt "Voraussetzungen").
fn apply_owner_permissions(
kind: MountKind,
owner_user: Option<&str>,
options: &mut Vec<String>,
) -> Result<()> {
if !matches!(kind, MountKind::Smb | MountKind::WebDav) {
return Ok(());
}
let Some(owner) = owner_user else {
return Ok(());
};
if !has_option(options, "uid") || !has_option(options, "gid") {
let (uid, gid) = resolve_uid_gid(owner)?;
if !has_option(options, "uid") {
options.push(format!("uid={uid}"));
}
if !has_option(options, "gid") {
options.push(format!("gid={gid}"));
}
}
if !has_option(options, "file_mode") {
options.push("file_mode=0700".to_string());
}
if !has_option(options, "dir_mode") {
options.push("dir_mode=0700".to_string());
}
Ok(())
}
/// Setzt `soft` als NFS-Standard, sofern der Nutzer nicht bereits selbst `hard`/`soft`/
/// `softerr` gesetzt hat. `hard` (der Standard von `mount.nfs`, wenn nichts angegeben ist)
/// lässt NFS-Anfragen unbegrenzt oft erneut versuchen, wenn der Server nicht antwortet -
/// genau das Einfrierverhalten (auch bei `umount`), das automatisches Umschalten unmöglich
/// machen würde. Siehe `man 5 nfs`, Abschnitt "soft / softerr / hard": dort wird ein
/// dauerhaft nicht erreichbarer Server als der Anwendungsfall genannt, für den `soft`
/// gedacht ist.
fn apply_nfs_resilience_defaults(kind: MountKind, options: &mut Vec<String>) {
if kind != MountKind::Nfs {
return;
}
if !has_option(options, "hard")
&& !has_option(options, "soft")
&& !has_option(options, "softerr")
{
options.push("soft".to_string());
}
}
/// Prüft, ob `options` bereits einen Eintrag für `key` enthält - entweder als `key=wert`
/// oder als bloßes Flag `key` (z. B. `soft`, `exec`).
fn has_option(options: &[String], key: &str) -> bool {
options
.iter()
.any(|o| o == key || o.starts_with(&format!("{key}=")))
}
fn resolve_uid_gid(username: &str) -> Result<(u32, u32)> {
Ok((run_id(username, "-u")?, run_id(username, "-g")?))
}
/// Macht `owner` (Nutzer + dessen primäre Gruppe) zum alleinigen Besitzer von `dir` (`0700`).
/// Für das Backing-Verzeichnis eines Paares mit gesetztem `owner_user` - unabhängig vom
/// Mount-Typ, also auch für NFS, wo es (anders als bei CIFS/WebDAV) keine `uid=`/`gid=`-
/// Mount-Option gibt, die die nach dem Mounten sichtbaren Rechte übersteuern könnte: das
/// Verzeichnis selbst gehört so wenigstens `owner` - reicht z. B. aus, um es zu betreten, bevor/
/// falls etwas gemountet ist, ersetzt aber nicht die serverseitige NFS-Export-Rechtevergabe
/// (siehe README, Abschnitt "Voller Zugriff für einen bestimmten Nutzer").
pub(crate) fn ensure_owner_only_dir(dir: &Path, owner: &str) -> Result<()> {
let (uid, gid) = resolve_uid_gid(owner)?;
std::os::unix::fs::chown(dir, Some(uid), Some(gid)).map_err(|e| Error::io(dir, e))?;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.map_err(|e| Error::io(dir, e))?;
Ok(())
}
fn run_id(username: &str, flag: &str) -> Result<u32> {
let output = std::process::Command::new("id")
.arg(flag)
.arg(username)
.output()
.map_err(|e| Error::Other(format!("could not run 'id': {e}")))?;
if !output.status.success() {
return Err(Error::Other(format!(
"user '{username}' not found ('id {flag} {username}' failed): {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.map_err(|e| {
Error::Other(format!(
"unexpected output from 'id {flag} {username}': {e}"
))
})
}
pub fn local_source(local: &LocalSide, settings: &GlobalSettings) -> Result<String> {
let ip = address::resolve_ip(&local.address, settings)?;
Ok(format_local_source(local, ip))
}
/// Formatiert die Mount-Quelle für eine bereits aufgelöste lokale IP, ohne selbst erneut
/// aufzulösen - für Aufrufer, die die IP ohnehin schon für eine andere Prüfung (z. B.
/// Erreichbarkeit) aufgelöst haben und damit eine zweite `mac2ip`-Auflösung derselben
/// MAC-Adresse vermeiden wollen.
pub fn format_local_source(local: &LocalSide, ip: std::net::Ipv4Addr) -> String {
format_source(local.kind, &ip.to_string(), &local.share)
}
pub fn cloud_source(cloud: &CloudSide) -> String {
match cloud.kind {
MountKind::WebDav => cloud.host_or_url.clone(),
MountKind::Smb | MountKind::Nfs => {
format_source(cloud.kind, &cloud.host_or_url, &cloud.share)
}
}
}
fn format_source(kind: MountKind, host: &str, share: &str) -> String {
let share = if share.starts_with('/') {
share.to_string()
} else {
format!("/{share}")
};
match kind {
MountKind::WebDav => format!("http://{host}{share}"),
MountKind::Smb => format!("//{host}{share}"),
MountKind::Nfs => format!("{host}:{share}"),
}
}
/// `extra_options` sind bereits einzelne Tokens (kein komma-getrennter String) - einfach
/// übernehmen. Frühere Versionen filterten hier auf `key=value`-Paare, wodurch bloße Flags
/// wie `soft`/`exec`/`ro` in `extra_options` still verworfen wurden - siehe Testfall unten.
fn parse_options(extra: &[String]) -> Vec<String> {
extra.to_vec()
}
/// Welche Seite aktuell aktiv ist: `pair.mount_point` muss auf das Backing-Verzeichnis dieser
/// Seite zeigen UND dieses Verzeichnis muss tatsächlich gemountet sein (Schutz gegen einen
/// veralteten Symlink, dessen Backing-Verzeichnis extern ausgehängt wurde).
pub fn active_side(pair: &DrivePair) -> Option<Side> {
let link_target = std::fs::read_link(&pair.mount_point).ok()?;
let side = if link_target == backing_dir(pair, Side::Local) {
Side::Local
} else if link_target == backing_dir(pair, Side::Cloud) {
Side::Cloud
} else {
return None;
};
match mount::state::is_mounted(&backing_dir(pair, side)) {
Ok(true) => Some(side),
_ => None,
}
}
/// Setzt `pair.mount_point` atomar als Symlink auf das Backing-Verzeichnis von `side`.
///
/// Atomar über `symlink` auf einen Temp-Pfad + `rename()` (POSIX-garantiert atomar auf
/// demselben Dateisystem) - es gibt also kein Zeitfenster, in dem der Pfad fehlt oder auf ein
/// veraltetes Ziel zeigt. Schlägt kontrolliert fehl (statt zu überschreiben), falls an
/// `pair.mount_point` bereits ein echtes Verzeichnis (kein Symlink) existiert.
pub fn activate_symlink(pair: &DrivePair, side: Side) -> Result<()> {
let link_path = &pair.mount_point;
if let Ok(meta) = std::fs::symlink_metadata(link_path)
&& !meta.file_type().is_symlink()
{
return Err(Error::Other(format!(
"'{}' already exists as a real directory (not as a symlink managed by smart-mount) - \
please remove/rename it manually before activating this pair.",
link_path.display()
)));
}
if let Some(parent) = link_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?;
// `mount_base_dir` ist von allen Paaren (mit ggf. unterschiedlichem `owner_user`)
// gemeinsam genutzt - hart auf `0755` setzen statt sich auf das Umask des root-Prozesses
// zu verlassen, damit jeder lokale Nutzer bis zum eigentlichen Mountpoint durchqueren
// kann (die eigentliche Zugriffsbeschränkung passiert weiter unten je Paar, siehe
// `ensure_owner_only_dir`/die `uid=`/`gid=`-Mount-Optionen).
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755))
.map_err(|e| Error::io(parent, e))?;
}
let target = backing_dir(pair, side);
let tmp_name = format!(
".{}.smart-mount-tmp",
link_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
);
let tmp_path = link_path.with_file_name(tmp_name);
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))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
fn sample_pair() -> DrivePair {
DrivePair {
id: "pair-1".into(),
name: "Test".into(),
enabled: true,
owner_user: None,
mount_point: "/media/smart-mount/pair-1".into(),
local: LocalSide {
kind: MountKind::Smb,
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)),
share: "share".into(),
username: None,
extra_options: vec!["vers=3.0".into()],
},
cloud: CloudSide {
kind: MountKind::WebDav,
host_or_url: "https://cloud.example.com/dav".into(),
share: "share".into(),
username: None,
extra_options: vec![],
},
}
}
#[test]
fn local_source_formats_smb_unc_path() {
let pair = sample_pair();
assert_eq!(
local_source(&pair.local, &GlobalSettings::default()).expect("local_source"),
"//192.168.1.10/share"
);
}
#[test]
fn local_source_propagates_mac2ip_error() {
let mut pair = sample_pair();
pair.local.address = crate::config::LocalAddress::Mac("00:11:22:33:44:55".into());
let err = local_source(&pair.local, &GlobalSettings::default()).unwrap_err();
assert!(matches!(err, crate::error::Error::Mac2Ip { .. }));
}
#[test]
fn cloud_source_uses_full_url_for_webdav() {
let pair = sample_pair();
assert_eq!(cloud_source(&pair.cloud), "https://cloud.example.com/dav");
}
#[test]
fn parse_options_passes_flags_and_key_value_pairs_through_unchanged() {
let opts = parse_options(&["vers=3.0".to_string(), "soft".to_string()]);
assert_eq!(opts, vec!["vers=3.0".to_string(), "soft".to_string()]);
}
#[test]
fn apply_owner_permissions_injects_uid_gid_and_owner_only_modes_for_cifs() {
let user = std::env::var("USER").expect("USER env var set in test environment");
let mut options = vec!["vers=3.0".to_string()];
apply_owner_permissions(MountKind::Smb, Some(&user), &mut options).expect("apply");
assert!(options.contains(&"file_mode=0700".to_string()));
assert!(options.contains(&"dir_mode=0700".to_string()));
assert!(options.iter().any(|o| o.starts_with("uid=")));
assert!(options.iter().any(|o| o.starts_with("gid=")));
// vorhandene Option bleibt unangetastet
assert!(options.contains(&"vers=3.0".to_string()));
}
#[test]
fn apply_owner_permissions_respects_explicit_overrides() {
let user = std::env::var("USER").expect("USER env var set in test environment");
let mut options = vec!["file_mode=0755".to_string()];
apply_owner_permissions(MountKind::WebDav, Some(&user), &mut options).expect("apply");
assert!(options.contains(&"file_mode=0755".to_string()));
assert!(!options.contains(&"file_mode=0700".to_string()));
assert!(options.contains(&"dir_mode=0700".to_string()));
}
#[test]
fn apply_owner_permissions_is_noop_for_nfs() {
let mut options = vec![];
apply_owner_permissions(MountKind::Nfs, Some("root"), &mut options).expect("apply");
assert!(options.is_empty());
}
#[test]
fn apply_owner_permissions_is_noop_without_owner_user() {
let mut options = vec![];
apply_owner_permissions(MountKind::Smb, None, &mut options).expect("apply");
assert!(options.is_empty());
}
#[test]
fn apply_owner_permissions_fails_clearly_for_unknown_user() {
let mut options = vec![];
let err = apply_owner_permissions(MountKind::Smb, Some("no-such-user-xyz"), &mut options)
.unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn apply_nfs_resilience_defaults_adds_soft_when_unset() {
let mut options = vec![];
apply_nfs_resilience_defaults(MountKind::Nfs, &mut options);
assert_eq!(options, vec!["soft".to_string()]);
}
#[test]
fn apply_nfs_resilience_defaults_respects_explicit_hard() {
let mut options = vec!["hard".to_string()];
apply_nfs_resilience_defaults(MountKind::Nfs, &mut options);
assert_eq!(options, vec!["hard".to_string()]);
}
#[test]
fn apply_nfs_resilience_defaults_respects_explicit_softerr() {
let mut options = vec!["softerr".to_string()];
apply_nfs_resilience_defaults(MountKind::Nfs, &mut options);
assert_eq!(options, vec!["softerr".to_string()]);
}
#[test]
fn apply_nfs_resilience_defaults_is_noop_for_other_kinds() {
let mut options = vec![];
apply_nfs_resilience_defaults(MountKind::Smb, &mut options);
assert!(options.is_empty());
}
#[test]
fn build_target_injects_soft_for_plain_nfs_pair() {
let mut pair = sample_pair();
pair.local.kind = MountKind::Nfs;
pair.cloud.kind = MountKind::Nfs;
let t = build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build");
assert!(t.options.contains(&"soft".to_string()));
}
#[test]
fn backing_dirs_are_unique_per_side_and_live_next_to_mount_point() {
let pair = sample_pair();
let local = backing_dir(&pair, Side::Local);
let cloud = backing_dir(&pair, Side::Cloud);
assert_ne!(local, cloud);
assert_eq!(local.parent(), pair.mount_point.parent());
assert_eq!(cloud.parent(), pair.mount_point.parent());
}
#[test]
fn activate_symlink_points_at_the_right_backing_dir_and_is_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
let mut pair = sample_pair();
pair.mount_point = dir.path().join("pair-1");
activate_symlink(&pair, Side::Local).expect("activate local");
assert_eq!(
std::fs::read_link(&pair.mount_point).unwrap(),
backing_dir(&pair, Side::Local)
);
// Erneutes Aktivieren derselben Seite darf nicht fehlschlagen (Symlink wird ersetzt,
// kein "existiert bereits als echtes Verzeichnis"-Fehler für einen eigenen Symlink).
activate_symlink(&pair, Side::Local).expect("re-activate local");
activate_symlink(&pair, Side::Cloud).expect("activate cloud");
assert_eq!(
std::fs::read_link(&pair.mount_point).unwrap(),
backing_dir(&pair, Side::Cloud)
);
}
#[test]
fn activate_symlink_refuses_to_clobber_a_real_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let mut pair = sample_pair();
pair.mount_point = dir.path().join("pair-1");
std::fs::create_dir_all(&pair.mount_point).expect("create real dir");
let err = activate_symlink(&pair, Side::Local).unwrap_err();
assert!(err.to_string().contains("real directory"));
}
#[test]
fn ensure_owner_only_dir_sets_mode_0700() {
let user = std::env::var("USER").expect("USER env var set in test environment");
let dir = tempfile::tempdir().expect("tempdir");
ensure_owner_only_dir(dir.path(), &user).expect("ensure_owner_only_dir");
let mode = std::fs::metadata(dir.path())
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o700);
}
#[test]
fn ensure_owner_only_dir_fails_clearly_for_unknown_user() {
let dir = tempfile::tempdir().expect("tempdir");
let err = ensure_owner_only_dir(dir.path(), "no-such-user-xyz").unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn activate_symlink_sets_shared_parent_dir_to_0755() {
let dir = tempfile::tempdir().expect("tempdir");
let parent = dir.path().join("mount-base");
let mut pair = sample_pair();
pair.mount_point = parent.join("pair-1");
activate_symlink(&pair, Side::Local).expect("activate local");
let mode = std::fs::metadata(&parent)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o755);
}
}
+417
View File
@@ -0,0 +1,417 @@
//! WebDAV-Backend (davfs2).
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::db::credentials::Credential;
use crate::error::{Error, Result};
use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
pub struct WebDavBackend;
const GUI_OPTIMIZE_COMMENT: &str =
"# smart-mount: gui_optimize enabled (batches PROPFIND requests for a directory)";
/// Deutlich über dem Standardwert (16), siehe [`ensure_buf_size`] für die Begründung.
const BUF_SIZE_KIB: &str = "16384";
const BUF_SIZE_COMMENT: &str = "# smart-mount: buf_size increased so directory contents are listed reliably even with many files";
impl MountBackend for WebDavBackend {
fn name(&self) -> &'static str {
"davfs2"
}
fn check_available(&self) -> Result<()> {
if binary_available("mount.davfs") {
Ok(())
} else {
Err(Error::BackendUnavailable {
backend: "davfs2",
reason: "'mount.davfs' not found - install package 'davfs2'".to_string(),
})
}
}
fn prepare(&self, target: &MountTarget, cred: Option<&Credential>) -> Result<()> {
let conf_path = davfs2_conf_path();
ensure_gui_optimize(&conf_path)?;
ensure_buf_size(&conf_path)?;
if let Some(cred) = cred
&& let Some(username) = &cred.username
{
if username.is_empty() || cred.password.is_empty() {
return Err(Error::Other(
"davfs2 credential has an empty username or password".to_string(),
));
}
write_secrets_entry(
&davfs2_secrets_path(),
&target.source,
username,
&cred.password,
)?;
}
Ok(())
}
fn mount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("mount");
cmd.arg("-t")
.arg("davfs")
.arg(&target.source)
.arg(&target.mount_point);
if !target.options.is_empty() {
cmd.arg("-o").arg(target.options.join(","));
}
run_tolerating_already_done(cmd, "davfs2 mount", false)
}
fn unmount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("umount");
cmd.arg(&target.mount_point);
run_tolerating_already_done(cmd, "davfs2 umount", true)
}
}
/// Mounten läuft ausschließlich als root/System-Dienst (siehe [`crate::systemd`]), daher immer
/// der System-Pfad - kein Nutzerkontext-Zweig mehr nötig.
fn davfs2_conf_path() -> PathBuf {
PathBuf::from("/etc/davfs2/davfs2.conf")
}
pub(crate) fn davfs2_secrets_path() -> PathBuf {
PathBuf::from("/etc/davfs2/secrets")
}
/// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen
/// Dateimanagern (die dazu neigen, jede Datei zu öffnen) die Reaktionszeit bei großen
/// Verzeichnissen, indem die "ist eine neuere Version vorhanden?"-Abfrage für ein ganzes
/// Verzeichnis in einem PROPFIND-Request gebündelt wird. Siehe `man davfs2.conf`.
fn ensure_gui_optimize(path: &Path) -> Result<()> {
ensure_config_line(path, "gui_optimize", "1", GUI_OPTIMIZE_COMMENT)
}
/// Setzt `buf_size` in `davfs2.conf` idempotent auf einen deutlich über dem Standard (16
/// KiB) liegenden Wert. Bekanntes Praxisproblem bei davfs2 (unabhängig davon, ob die
/// `man davfs2.conf`-Beschreibung - reiner I/O-Geschwindigkeits-Tuningparameter - das exakt
/// so vorsieht): bei zu kleinem `buf_size` liefert `ls` in Verzeichnissen mit vielen Dateien
/// einen leeren/unvollständigen Inhalt zurück, obwohl einzelne Dateien direkt geöffnet werden
/// können (der FUSE-readdir-Puffer wird dabei stillschweigend abgeschnitten). Ein deutlich
/// größerer Puffer behebt das bei vertretbarem Speicher-Mehrverbrauch für einen einzelnen
/// Mount.
fn ensure_buf_size(path: &Path) -> Result<()> {
ensure_config_line(path, "buf_size", BUF_SIZE_KIB, BUF_SIZE_COMMENT)
}
/// Setzt `<key> <value>` in einer davfs2-Konfigurationsdatei idempotent: ersetzt eine
/// bestehende (unkommentierte) Zeile mit demselben Schlüssel (unabhängig vom bisherigen
/// Wert) statt sie zu duplizieren, und lässt alle anderen Zeilen unangetastet.
fn ensure_config_line(path: &Path, key: &str, value: &str, comment: &str) -> Result<()> {
let existing = fs::read_to_string(path).unwrap_or_default();
let is_key_line = |line: &str| {
let trimmed = line.trim();
!trimmed.starts_with('#') && trimmed.split_whitespace().next() == Some(key)
};
if existing
.lines()
.any(|l| is_key_line(l) && l.split_whitespace().nth(1) == Some(value))
{
return Ok(());
}
let mut new_lines: Vec<String> = existing
.lines()
.filter(|l| !is_key_line(l))
.map(str::to_string)
.collect();
new_lines.push(comment.to_string());
new_lines.push(format!("{key} {value}"));
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
}
fs::write(path, format!("{}\n", new_lines.join("\n"))).map_err(|e| Error::io(path, e))
}
/// Erste (durch Leerraum getrennte oder in Anführungszeichen gefasste) Spalte einer
/// `secrets`-Zeile - die URL, gegen die eine Zeile identifiziert wird.
fn secrets_entry_url(line: &str) -> Option<&str> {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix('"') {
return rest.split('"').next();
}
trimmed.split_whitespace().next()
}
/// davfs2 trennt Felder in der `secrets`-Datei per Leerraum; enthält ein Wert Leerraum, muss
/// er nach `man davfs2.conf` in doppelte Anführungszeichen gefasst werden. Ohne diese
/// Behandlung würde davfs2 ein Passwort mit Leerzeichen stillschweigend am ersten Leerzeichen
/// abschneiden.
fn quote_secrets_field(value: &str) -> String {
if value.chars().any(char::is_whitespace) {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value.to_string()
}
}
/// Schreibt/aktualisiert eine Zeile in davfs2s `secrets`-Datei (`<url> <username> <password>`,
/// muss chmod 600 sein). Ersetzt eine bestehende Zeile für dieselbe URL statt sie zu duplizieren.
fn write_secrets_entry(path: &PathBuf, url: &str, username: &str, password: &str) -> Result<()> {
let existing = fs::read_to_string(path).unwrap_or_default();
let mut lines: Vec<String> = existing
.lines()
.filter(|l| secrets_entry_url(l) != Some(url))
.map(str::to_string)
.collect();
lines.push(format!(
"{} {} {}",
quote_secrets_field(url),
quote_secrets_field(username),
quote_secrets_field(password)
));
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
}
}
#[cfg(unix)]
let mut opts = OpenOptions::new();
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
#[cfg(not(unix))]
let mut opts = OpenOptions::new();
let mut file = opts
.write(true)
.create(true)
.truncate(true)
.open(path)
.map_err(|e| Error::io(path, e))?;
file.write_all(format!("{}\n", lines.join("\n")).as_bytes())
.map_err(|e| Error::io(path, e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
Ok(())
}
/// Entfernt (best-effort) die Zeile für `url` aus der `secrets`-Datei, falls vorhanden - für
/// `smart-mount drive remove`, damit kein Klartext-Passwort für ein gelöschtes Paar zurückbleibt.
///
/// **Bekannte Grenze:** die Zeile ist über die URL indiziert (inkl. IP bei MAC-adressierten
/// lokalen Seiten). Hat sich die IP seit dem letzten Mount geändert, berechnet der Aufrufer
/// eine andere, aktuelle URL als die tatsächlich gespeicherte - die eigentliche Alt-Zeile
/// bleibt dann zurück. Kein Fehler, falls die Datei nicht existiert oder `url` nicht enthält.
pub(crate) fn remove_secrets_entry(path: &Path, url: &str) -> Result<()> {
let Ok(existing) = fs::read_to_string(path) else {
return Ok(());
};
let remaining: Vec<&str> = existing
.lines()
.filter(|l| secrets_entry_url(l) != Some(url))
.collect();
if remaining.len() == existing.lines().count() {
return Ok(());
}
fs::write(path, format!("{}\n", remaining.join("\n"))).map_err(|e| Error::io(path, e))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ensure_gui_optimize_is_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("davfs2.conf");
ensure_gui_optimize(&path).expect("first call");
let first = fs::read_to_string(&path).expect("read");
ensure_gui_optimize(&path).expect("second call");
let second = fs::read_to_string(&path).expect("read");
assert_eq!(first, second);
assert_eq!(
first
.lines()
.filter(|l| l.trim() == "gui_optimize 1")
.count(),
1
);
}
#[test]
fn ensure_gui_optimize_replaces_disabled_value() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("davfs2.conf");
fs::write(&path, "use_locks 1\ngui_optimize 0\n").expect("write");
ensure_gui_optimize(&path).expect("patch");
let contents = fs::read_to_string(&path).expect("read");
assert!(contents.contains("use_locks 1"));
assert!(contents.contains("gui_optimize 1"));
assert!(!contents.contains("gui_optimize 0"));
}
#[test]
fn ensure_buf_size_raises_the_default_and_is_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("davfs2.conf");
fs::write(&path, "buf_size 16\n").expect("write default");
ensure_buf_size(&path).expect("first call");
let first = fs::read_to_string(&path).expect("read");
ensure_buf_size(&path).expect("second call");
let second = fs::read_to_string(&path).expect("read");
assert_eq!(first, second);
assert!(
!first.contains("buf_size 16\n") && !first.lines().any(|l| l.trim() == "buf_size 16")
);
assert!(
first
.lines()
.any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}"))
);
assert_eq!(
first
.lines()
.filter(|l| l.trim().starts_with("buf_size "))
.count(),
1
);
}
#[test]
fn gui_optimize_and_buf_size_coexist_without_clobbering_each_other() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("davfs2.conf");
ensure_gui_optimize(&path).expect("gui_optimize");
ensure_buf_size(&path).expect("buf_size");
let contents = fs::read_to_string(&path).expect("read");
assert!(contents.lines().any(|l| l.trim() == "gui_optimize 1"));
assert!(
contents
.lines()
.any(|l| l.trim() == format!("buf_size {BUF_SIZE_KIB}"))
);
}
#[test]
fn write_secrets_entry_replaces_existing_line_for_same_url() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets");
write_secrets_entry(&path, "https://cloud/dav", "user", "old-pass").expect("write 1");
write_secrets_entry(&path, "https://cloud/dav", "user", "new-pass").expect("write 2");
let contents = fs::read_to_string(&path).expect("read");
assert_eq!(
contents
.lines()
.filter(|l| l.contains("https://cloud/dav"))
.count(),
1
);
assert!(contents.contains("new-pass"));
assert!(!contents.contains("old-pass"));
}
#[test]
fn write_secrets_entry_quotes_a_password_containing_whitespace() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets");
write_secrets_entry(&path, "https://cloud/dav", "user", "my pass word").expect("write");
let contents = fs::read_to_string(&path).expect("read");
assert!(contents.contains("\"my pass word\""));
}
#[test]
fn write_secrets_entry_does_not_clobber_a_url_that_is_a_prefix_of_another() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets");
write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1").expect("write 1");
write_secrets_entry(&path, "http://192.168.1.5/dav-archive", "user2", "pass2")
.expect("write 2");
write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1-updated")
.expect("write 3");
let contents = fs::read_to_string(&path).expect("read");
assert!(contents.contains("pass1-updated"));
assert!(!contents.contains("pass1\n") && !contents.ends_with("pass1"));
assert!(
contents.contains("pass2"),
"unrelated prefix-matching URL's entry must survive"
);
}
#[test]
fn remove_secrets_entry_deletes_only_the_matching_url() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets");
write_secrets_entry(&path, "https://cloud/dav", "user1", "pass1").expect("write 1");
write_secrets_entry(&path, "http://192.168.1.5/dav", "user2", "pass2").expect("write 2");
remove_secrets_entry(&path, "https://cloud/dav").expect("remove");
let contents = fs::read_to_string(&path).expect("read");
assert!(!contents.contains("https://cloud/dav"));
assert!(!contents.contains("pass1"));
assert!(contents.contains("http://192.168.1.5/dav"));
assert!(contents.contains("pass2"));
}
#[test]
fn remove_secrets_entry_does_not_remove_a_url_that_the_target_url_is_a_prefix_of() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("secrets");
write_secrets_entry(&path, "http://192.168.1.5/dav", "user1", "pass1").expect("write 1");
write_secrets_entry(&path, "http://192.168.1.5/dav-archive", "user2", "pass2")
.expect("write 2");
remove_secrets_entry(&path, "http://192.168.1.5/dav").expect("remove");
let contents = fs::read_to_string(&path).expect("read");
assert!(!contents.contains("pass1"));
assert!(
contents.contains("http://192.168.1.5/dav-archive") && contents.contains("pass2"),
"removing the shorter URL must not delete the longer URL's entry"
);
}
#[test]
fn remove_secrets_entry_is_a_noop_for_missing_file_or_unmatched_url() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("does-not-exist");
assert!(remove_secrets_entry(&path, "https://cloud/dav").is_ok());
write_secrets_entry(&path, "https://cloud/dav", "user", "pass").expect("write");
assert!(remove_secrets_entry(&path, "https://other/dav").is_ok());
let contents = fs::read_to_string(&path).expect("read");
assert!(contents.contains("https://cloud/dav"));
}
}
+18
View File
@@ -0,0 +1,18 @@
//! Auflösung der konfigurierten lokalen Adresse (IP oder MAC) zu einer konkreten IPv4-Adresse.
use std::net::Ipv4Addr;
use crate::config::{GlobalSettings, LocalAddress};
use crate::error::Result;
use crate::network::mac2ip;
/// Löst eine [`LocalAddress`] zu einer konkreten IPv4-Adresse auf.
///
/// `Ip`-Adressen werden direkt durchgereicht, `Mac`-Adressen über das externe `mac2ip`-Tool
/// aufgelöst (siehe [`mac2ip::resolve`]).
pub fn resolve_ip(address: &LocalAddress, settings: &GlobalSettings) -> Result<Ipv4Addr> {
match address {
LocalAddress::Ip(ip) => Ok(*ip),
LocalAddress::Mac(mac) => mac2ip::resolve(mac, &settings.mac2ip_binary),
}
}
+123
View File
@@ -0,0 +1,123 @@
//! Isolierter Wrapper um das externe, private `mac2ip`-CLI-Tool.
//!
//! `mac2ip` ist kein Rust-Crate, sondern ein eigenständiges, bereits vorhandenes CLI-Tool
//! des Nutzers (siehe Projekt "Mac2Ip"). smart-mount ruft es ausschließlich als Subprozess
//! auf; die JSON-Ausgabeform (`{"status":"ok","mac":..,"ip":..,"source":..}` bzw.
//! `{"status":"error","mac":..,"error":..}`) wurde gegen den tatsächlichen Quellcode
//! (`src/output.rs`) verifiziert.
//!
//! `--auto-trust-networks` wird immer mitgegeben: mac2ip beantwortet damit seine eigene
//! "nmap-Scan in diesem Netzwerk erlauben?"-Rückfrage automatisch mit Ja und merkt sich das
//! Netzwerk dauerhaft in seiner eigenen Cache-Datenbank - funktional identisch zum manuellen
//! Eintragen in mac2ips Config, aber ohne dass smart-mount das Config-Schema eines fremden
//! Tools kennen oder dort hineinschreiben muss.
use std::net::Ipv4Addr;
use std::process::Command;
use serde::Deserialize;
use crate::error::{Error, Result};
/// Timeout in Sekunden für den `mac2ip`-Subprozess (dieselbe `timeout`-Coreutils-Technik wie
/// [`crate::mount::run_with_timeout`]). `mac2ip` kann intern einen `nmap`-Scan auslösen -
/// gegen ein dauerhaft nicht erreichbares/reagierendes Netz sonst ein unbegrenztes Hängen, das
/// `smart-mount watch`/`status` komplett blockieren würde, statt korrekt "nicht auflösbar" zu
/// melden.
const MAC2IP_TIMEOUT_SECS: u64 = 15;
#[derive(Deserialize)]
#[serde(untagged)]
enum Mac2IpOutput {
Success { ip: Ipv4Addr },
Failure { error: String },
}
/// Löst eine MAC-Adresse über das externe `mac2ip`-Tool zu einer IPv4-Adresse auf.
///
/// `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> {
let output = Command::new("timeout")
.arg(MAC2IP_TIMEOUT_SECS.to_string())
.arg(binary)
.args(["--json", "--auto-trust-networks", mac])
.output()
.map_err(|e| Error::Mac2Ip {
mac: mac.to_string(),
reason: format!("could not run '{binary}': {e}"),
})?;
if output.status.code() == Some(124) {
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)
}
/// Prüft, ob das konfigurierte `mac2ip`-Binary über `PATH` auffindbar ist.
pub fn is_installed(binary: &str) -> bool {
Command::new("which")
.arg(binary)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn parse_output(stdout: &[u8], mac: &str) -> Result<Ipv4Addr> {
let text = String::from_utf8_lossy(stdout);
let line = text.lines().next().unwrap_or("").trim();
if line.is_empty() {
return Err(Error::Mac2Ip {
mac: mac.to_string(),
reason: "no output received from mac2ip".to_string(),
});
}
match serde_json::from_str::<Mac2IpOutput>(line) {
Ok(Mac2IpOutput::Success { ip }) => Ok(ip),
Ok(Mac2IpOutput::Failure { error }) => Err(Error::Mac2Ip {
mac: mac.to_string(),
reason: error,
}),
Err(e) => Err(Error::Mac2Ip {
mac: mac.to_string(),
reason: format!("could not parse output as JSON: {e} (output: '{line}')"),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_success_output() {
let stdout =
br#"{"status":"ok","mac":"aa:bb:cc:dd:ee:ff","ip":"192.168.1.42","source":"cache"}"#;
let ip = parse_output(stdout, "aa:bb:cc:dd:ee:ff").expect("should parse");
assert_eq!(ip, Ipv4Addr::new(192, 168, 1, 42));
}
#[test]
fn parses_failure_output_as_error() {
let stdout =
br#"{"status":"error","mac":"aa:bb:cc:dd:ee:ff","error":"keine IP-Adresse gefunden"}"#;
let err = parse_output(stdout, "aa:bb:cc:dd:ee:ff").unwrap_err();
assert!(matches!(err, Error::Mac2Ip { .. }));
}
#[test]
fn empty_output_is_an_error_not_a_panic() {
assert!(parse_output(b"", "aa:bb:cc:dd:ee:ff").is_err());
}
#[test]
fn garbage_output_is_an_error_not_a_panic() {
assert!(parse_output(b"not json at all", "aa:bb:cc:dd:ee:ff").is_err());
}
}
+89 -2
View File
@@ -1,2 +1,89 @@
pub mod network_interface;
pub mod utils;
//! Erreichbarkeitsprüfungen und lokale Adressauflösung.
pub mod address;
pub mod mac2ip;
use std::process::{Command, Stdio};
/// Prüft, ob `addr` erreichbar ist. Erkennt anhand des Präfixes, ob es sich um eine URL
/// (HTTP HEAD via `curl`) oder eine reine Host-/IP-Adresse (ICMP-Ping) handelt.
///
/// Portiert aus dem alten `src/network/utils.rs` (v0.2.0).
///
/// Für HTTP wird bewusst NICHT `curl --fail` verwendet: ein WebDAV-Server, der auf das HEAD
/// mit 401/403 (Auth erforderlich) antwortet, ist trotzdem erreichbar - `--fail` würde das als
/// Fehler werten. Der tatsächliche Statuscode wird stattdessen ausgelesen und geprüft: nur ein
/// Serverfehler (5xx) gilt als NICHT erreichbar, da ein dauerhaft 5xx-antwortender Server
/// (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 {
if addr.starts_with("http://") || addr.starts_with("https://") {
Command::new("curl")
.args([
"--head",
"--silent",
"--max-time",
"5",
"--output",
"/dev/null",
"--write-out",
"%{http_code}",
addr,
])
.stderr(Stdio::null())
.output()
.ok()
.and_then(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<u16>()
.ok()
})
.is_some_and(|code| (100..500).contains(&code))
} else {
Command::new("ping")
.args(["-c", "1", "-W", "2", addr])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::net::TcpListener;
#[test]
fn http_401_endpoint_is_reported_as_reachable() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("port").port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let response = "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n";
let _ = stream.write_all(response.as_bytes());
}
});
assert!(is_reachable(&format!("http://127.0.0.1:{port}")));
}
#[test]
fn http_500_endpoint_is_reported_as_unreachable() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().expect("port").port();
std::thread::spawn(move || {
if let Ok((mut stream, _)) = listener.accept() {
let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n";
let _ = stream.write_all(response.as_bytes());
}
});
assert!(!is_reachable(&format!("http://127.0.0.1:{port}")));
}
}
-192
View File
@@ -1,192 +0,0 @@
use crate::log;
use crate::log::LogLevel;
use std::process::Command;
/// Prüft, ob mindestens eine verfügbare Netzwerkschnittstelle (LAN/WLAN etc.) aktiv ist
/// und nicht nur die Loopback-Schnittstelle.
///
/// Rückgabe:
/// - Option<String>: Name der aktiven Netzwerkkarte oder None wenn keine gefunden
pub fn get_active_network_interface() -> Option<String> {
#[cfg(target_os = "linux")]
{
use std::fs;
use std::path::Path;
// Prüfe Interfaces in /sys/class/net
let sysfs = Path::new("/sys/class/net");
if let Ok(entries) = fs::read_dir(sysfs) {
for entry in entries.flatten() {
let ifname = match entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
if ifname == "lo" {
continue;
}
let iface_path = entry.path();
// 1) operstate == "up"
let operstate = fs::read_to_string(iface_path.join("operstate"))
.unwrap_or_default()
.trim()
.to_string();
if operstate != "up" {
continue;
}
// 2) carrier == "1" (hat Link)
let carrier = fs::read_to_string(iface_path.join("carrier"))
.unwrap_or_default()
.trim()
.to_string();
if carrier != "1" {
continue;
}
// 3) Virtuell ausschließen:
// /sys/class/net/<if>/device -> Symlink zu physischer HW;
// zeigt der Pfad unterhalb von ".../virtual/..." oder existiert er gar nicht,
// dann ist das i. d. R. ein virtuelles Interface.
let device_link = iface_path.join("device");
let is_virtual = match fs::read_link(&device_link) {
Ok(target) => target.as_os_str().to_string_lossy().contains("/virtual/"),
Err(_) => true, // kein device-Link -> in der Regel virtuell
};
if is_virtual {
continue;
}
// Optional: bekannte „logische" Typen via Verzeichnis-Erkennung ausschließen
// (Bridges, VLANs, Macvlan etc.)
if iface_path.join("bridge").exists()
|| iface_path.join("bonding").exists()
|| iface_path.join("team").exists()
|| iface_path.join("vlan").exists()
|| iface_path.join("macvlan").exists()
{
continue;
}
// Wenn wir hier sind, haben wir eine aktive physische NIC
log::log("network_interface", "Found active network interface.", LogLevel::Info);
return Some(ifname);
}
}
let output = Command::new("ip")
.args(["-o", "link", "show", "up"])
.output();
if let Ok(out) = output {
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if let Some(rest) = line.splitn(2, ": ").nth(1) {
if let Some(ifname) = rest.split(':').next() {
let name = ifname.trim();
if name != "lo" {
log::log("network_interface", "Found active network interface.", LogLevel::Debug);
return Some(name.to_string());
}
}
}
}
}
None
}
#[cfg(target_os = "windows")]
{
// Windows: PowerShell verwenden, um aktive Adapter zu finden (Status = Up).
// Wir ignorieren Loopback/Pseudo-Interfaces, indem wir Physical=true filtern.
let ps_cmd = r#"
$adapters = Get-NetAdapter -Physical | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1
if ($adapters) { $adapters.Name } else { 'NONE' }
"#;
let output = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
.output();
if let Ok(out) = output {
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if stdout != "NONE" {
log::log("network_interface", "Found active network interface.", LogLevel::Debug);
return Some(stdout);
}
}
None
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
log::log("network_interface", "OS not supported.", LogLevel::Error);
None
}
}
/// Ermittelt die IP-Adresse (mit Netzmaske) für eine angegebene Netzwerkschnittstelle
///
/// # Parameter
/// - `interface`: Name der Netzwerkschnittstelle
///
/// # Rückgabe
/// - Option<String>: IP-Adresse mit Netzmaske (z.B. "192.168.1.100/24") oder None wenn keine gefunden
pub fn get_interface_ip_address(interface: &str) -> Option<String> {
#[cfg(target_os = "linux")]
{
let output = Command::new("ip")
.args(["addr", "show", interface])
.output();
if let Ok(out) = output {
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
if line.contains("inet ") {
if let Some(ip) = line.split_whitespace().nth(1) {
return Some(ip.to_string());
}
}
}
}
None
}
#[cfg(target_os = "windows")]
{
let ps_cmd = format!(
r#"Get-NetIPAddress -InterfaceAlias '{}' -AddressFamily IPv4 | Select-Object IPAddress,PrefixLength | ForEach-Object {{ "{0}/{1}" -f $_.IPAddress,$_.PrefixLength }}"#,
interface
);
let output = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
.output();
if let Ok(out) = output {
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !stdout.is_empty() {
return Some(stdout);
}
}
None
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
log::log("network_interface", "OS not supported.", LogLevel::Error);
None
}
}
-391
View File
@@ -1,391 +0,0 @@
use crate::log;
use crate::log::LogLevel;
use std::net::Ipv4Addr;
use std::process::Command;
/// Prüft, ob eine angegebene IP-Adresse oder URL erreichbar ist
///
/// # Parameter
/// - `address`: Eine IP-Adresse oder vollständige HTTPS-URL
///
/// # Rückgabe
/// - `bool`: True wenn die Adresse erreichbar ist, False wenn nicht
pub fn is_reachable(address: &str) -> bool {
let addr_string = if let Some((ip_str, mask_str)) = address.split_once('/') {
if ip_str.parse::<Ipv4Addr>().is_ok()
&& mask_str.parse::<u8>().ok().filter(|m| *m <= 32).is_some()
{
let ip = ip_str.parse::<Ipv4Addr>().unwrap();
let next_ip = Ipv4Addr::from(u32::from(ip).saturating_add(1));
next_ip.to_string()
} else {
address.to_string()
}
} else {
address.to_string()
};
let addr = addr_string.as_str();
#[cfg(target_os = "linux")]
{
if addr.parse::<Ipv4Addr>().is_ok() {
let output = Command::new("ping")
.args(["-c", "1", "-W", "2", addr])
.output();
if let Ok(out) = output {
let stdout = String::from_utf8_lossy(&out.stdout);
log("network_utils", &*stdout, LogLevel::Debug);
out.status.success()
} else {
log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error);
false
}
} else {
let output = Command::new("curl")
.args(["--head", "--silent", "--fail", addr])
.output();
if let Ok(out) = output {
out.status.success()
} else {
log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error);
false
}
}
}
#[cfg(target_os = "windows")]
{
if addr.parse::<Ipv4Addr>().is_ok() {
let output = Command::new("ping")
.args(["-n", "1", "-w", "2000", addr])
.output();
if let Ok(out) = output {
out.status.success()
} else {
log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error);
false
}
} else {
let output = Command::new("powershell")
.args(["-Command", &format!("Invoke-WebRequest -Uri {} -Method HEAD -UseBasicParsing", addr)])
.output();
if let Ok(out) = output {
out.status.success()
} else {
log::log("network_utils", &*format!("Couldn't reach address {}!", addr), LogLevel::Error);
false
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
log::log("network_utils", "OS not supported.", LogLevel::Error);
false
}
}
/// Berechnet die Netzwerkadresse aus einer IP-Adresse mit Subnetzmaske
///
/// # Parameter
/// - `address`: Eine IPv4-Adresse mit Subnetzmaske im Format "xxx.xxx.xxx.xxx/yy"
///
/// # Rückgabe
/// - Option<String>: Die Netzwerkadresse im gleichen Format oder None bei ungültiger Eingabe
pub fn get_network_address(address: &str) -> Option<String> {
let parts: Vec<&str> = address.split('/').collect();
if parts.len() != 2 {
return None;
}
if let Ok(ip) = parts[0].parse::<Ipv4Addr>() {
if let Ok(mask) = parts[1].parse::<u8>() {
if mask > 32 {
return None;
}
let ip_bits: u32 = u32::from(ip);
let mask_bits: u32 = !0u32 << (32 - mask);
let network = Ipv4Addr::from(ip_bits & mask_bits);
return Some(format!("{}/{}", network, mask));
}
}
None
}
/// Ermittelt die IP-Adresse zu einer gegebenen MAC-Adresse im lokalen Netzwerk.
/// Verwendet einen nmap-Scan, um das Gerät zu finden.
///
/// # Parameter
/// - `mac`: MAC-Adresse im Format "xx:xx:xx:xx:xx:xx"
/// - `network`: Netzwerkadresse im Format "xxx.xxx.xxx.xxx/yy"
///
/// # Rückgabe
/// - Option<String>: Die IP-Adresse des Geräts oder None wenn nicht gefunden
pub fn get_ip_from_mac(mac: &str, network: &str) -> Option<String> {
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
let output = Command::new("nmap")
.args(["-sn", "-n", "--system-dns", "-PR", network])
.output();
match output {
Ok(out) => {
if !out.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&out.stdout);
log("network_utils", &*stdout, LogLevel::Debug);
// MAC normalisieren: nur Hex-Zeichen, klein, ohne Trennzeichen
let normalize_mac = |m: &str| -> String {
m.chars()
.filter(|c| c.is_ascii_hexdigit())
.flat_map(|c| c.to_lowercase())
.collect::<String>()
};
let target_mac = normalize_mac(mac);
// Beispielausgaben:
// Nmap scan report for 192.168.0.10
// Nmap scan report for printer.lan (192.168.0.10)
// MAC Address: 00:11:22:33:44:55 (Vendor)
//
// Strategie:
// - Merke die zuletzt gesehene IP aus "Nmap scan report for ..."
// - Wenn danach eine "MAC Address:"-Zeile folgt und MAC passt -> gib IP zurück
let mut current_ip: Option<String> = None;
for raw_line in stdout.lines() {
let line = raw_line.trim();
if line.starts_with("Nmap scan report for") {
// Versuche IP zu extrahieren:
// 1) Klammerform: ... (x.x.x.x)
// 2) Sonst letztes Token als IP
let ip = if let Some(start) = line.rfind('(') {
if let Some(end) = line.rfind(')') {
let in_parens = &line[start + 1..end];
Some(in_parens.to_string())
} else {
None
}
} else {
line.split_whitespace()
.last()
.map(|s| s.to_string())
};
// Grobe Validierung IPv4
if let Some(ip_str) = ip {
let maybe_ip = ip_str.parse::<Ipv4Addr>().ok().map(|ip| ip.to_string());
current_ip = maybe_ip;
} else {
current_ip = None;
}
} else if line.starts_with("MAC Address:") {
// Format: "MAC Address: XX:XX:XX:XX:XX:XX (Vendor)"
let mac_part = line.strip_prefix("MAC Address:").unwrap().trim();
let mac_token = mac_part.split_whitespace().next().unwrap_or("");
let seen_mac = normalize_mac(mac_token);
if !seen_mac.is_empty() && seen_mac == target_mac {
if let Some(ip) = current_ip.clone() {
return Some(ip);
}
}
}
}
None
}
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
log::log("network_utils", "nmap is not installed on the system!", LogLevel::Error);
}
None
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
log::log("network_utils", "OS not supported.", LogLevel::Error);
None
}
}
/// Ermittelt die MAC-Adresse zu einer gegebenen IP-Adresse im lokalen Netzwerk
/// durch Auslesen der ARP-Tabelle.
///
/// # Parameter
/// - `ip`: IP-Adresse für die die MAC-Adresse ermittelt werden soll
///
/// # Rückgabe
/// - Option<String>: Die MAC-Adresse des Geräts oder None wenn nicht gefunden
pub fn get_mac_from_ip(ip: &str) -> Option<String> {
// Hilfsfunktionen zur MAC-Normalisierung
let normalize_mac = |m: &str| -> String {
m.chars()
.filter(|c| c.is_ascii_hexdigit())
.flat_map(|c| c.to_lowercase())
.collect::<String>()
};
let canonicalize_mac = |hex_no_sep: &str| -> String {
hex_no_sep
.as_bytes()
.chunks(2)
.map(std::str::from_utf8)
.filter_map(Result::ok)
.collect::<Vec<&str>>()
.join(":")
};
#[cfg(target_os = "linux")]
{
// 1) Ziel kurz anpingen, damit ein ARP-Eintrag entsteht
let _ = Command::new("ping")
.args(["-c", "1", "-W", "1", ip])
.output();
// 2) Bis zu drei Versuche, ARP/Neigh einzulesen (kleine Wartezeit)
for _ in 0..3 {
let output = Command::new("ip")
.args(["neigh", "show", ip])
.output();
if let Ok(out) = output {
if out.status.success() {
let stdout = String::from_utf8_lossy(&out.stdout);
// Beispiel: 192.168.1.1 dev eth0 lladdr 00:11:22:33:44:55 REACHABLE
for line in stdout.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.get(0).copied() == Some(ip) {
if let Some(idx) = parts.iter().position(|p| *p == "lladdr") {
if let Some(mac_tok) = parts.get(idx + 1) {
let seen_norm = normalize_mac(mac_tok);
if seen_norm.len() == 12 {
return Some(canonicalize_mac(&seen_norm));
}
}
}
}
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
None
}
#[cfg(target_os = "windows")]
{
// 1) Ziel kurz anpingen, damit ein ARP-Eintrag entsteht
let _ = Command::new("ping")
.args(["-n", "1", "-w", "500", ip])
.output();
// 2) Bis zu drei Versuche, ARP einzulesen (kleine Wartezeit)
for _ in 0..3 {
// Hinweis: `arp -a` auf Windows zeigt die gesamte Tabelle; mit IP filtert es i. d. R. auf Interface,
// daher filtern wir inhaltlich auf die Zeile mit der Ziel-IP.
let output = Command::new("arp")
.args(["-a"])
.output();
if let Ok(out) = output {
if out.status.success() {
let stdout = String::from_utf8_lossy(&out.stdout);
for line in stdout.lines() {
let cols: Vec<&str> = line.split_whitespace().collect();
// Typisch: "192.168.1.1 00-11-22-33-44-55 dynamic"
if cols.get(0).copied() == Some(ip) && cols.len() >= 2 {
let mac_colon = cols[1].replace('-', ":");
let seen_norm = normalize_mac(&mac_colon);
if seen_norm.len() == 12 {
return Some(canonicalize_mac(&seen_norm));
}
}
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
None
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
log::log("network_utils", "OS not supported.", LogLevel::Error);
None
}
}
/// Sendet ein Wake-on-LAN Magic Packet an eine MAC-Adresse
///
/// # Parameter
/// - `mac`: MAC-Adresse im Format "xx:xx:xx:xx:xx:xx"
///
/// # Funktionsweise
/// Sendet ein Magic Packet bestehend aus:
/// - 6 Bytes 0xFF (Synchronisierung)
/// - 16x die MAC-Adresse wiederholt
/// - Port 9 (Standard Wake-on-LAN Port)
pub fn wake_on_land(mac: &str) {
#[cfg(target_os = "linux")]
{
let output = Command::new("wakeonlan")
.arg(mac)
.output();
if let Ok(out) = output {
if !out.status.success() {
log("network_utils", "Failed to send WOL packet!", LogLevel::Error);
}
} else {
log("network_utils", "wakeonlan is not installed on the system!", LogLevel::Error);
}
}
#[cfg(target_os = "windows")]
{
let ps_cmd = format!(
r#"
$mac="{}"
$macByteArray=[byte[]]::new(102)
6..101 | %{{ $macByteArray[$_]=0xFF }}
$macAddr=[byte[]]::new(6)
$mac -split "[:-]" | %{{$macAddr[$i]=[Convert]::ToByte($_,16); $i++}}
6..101 | %{{ $macByteArray[$_]=$macAddr[($_ - 6) % 6] }}
$UdpClient=New-Object System.Net.Sockets.UdpClient
$UdpClient.Connect(([System.Net.IPAddress]::Broadcast),9)
$UdpClient.Send($macByteArray,$macByteArray.Length)
"#,
mac
);
let output = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
.output();
if let Err(_) = output {
log::log("network_utils", "Failed to send WOL packet!", LogLevel::Error);
}
}
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
{
log::log("network_utils", "OS not supported.", LogLevel::Error);
}
}
-14
View File
@@ -1,14 +0,0 @@
use std::env;
/// Liefert den Programmnamen (Dateistamm der aktuellen ausführbaren Datei).
///
/// Rückgabe:
/// - Dateistamm der aktuellen Executable als `String`.
/// - Fallback `"app"`, wenn der Name nicht ermittelt werden kann.
pub(crate) fn program_name() -> String {
env::current_exe()
.ok()
.and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string()))
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "app".to_string())
}
+424
View File
@@ -0,0 +1,424 @@
//! Watchdog-Reconciling: entscheidet pro Laufwerkspaar, ob lokal oder Cloud eingebunden
//! sein sollte, und schaltet bei Bedarf um.
//!
//! `watch_once` ist bewusst ein einzelner, synchroner Durchlauf ohne internen Sleep-Loop -
//! dieselbe Kommandozeile (`smart-mount watch`) funktioniert dadurch sowohl unter einem
//! systemd-Timer als auch als Crontab-Zeile.
//!
//! **Zu jedem Zeitpunkt ist im Ruhezustand genau eine Seite gemountet** (wie ursprünglich
//! vorgesehen) - der sichtbare `pair.mount_point` ist ein Symlink auf das jeweils aktive
//! Backing-Verzeichnis (siehe [`crate::mount::target`]). Nur *während* eines aktiven
//! Umschaltens sind kurz beide Backing-Verzeichnisse gemountet: [`switch_to`] mountet die
//! neue Seite zuerst, flippt dann den Symlink, und hängt erst danach die alte Seite aus - so
//! gibt es nie ein Zeitfenster, in dem der sichtbare Pfad auf nichts Gemountetes zeigt.
use std::net::Ipv4Addr;
use crate::config::{AppConfig, DrivePair, GlobalSettings, LocalSide};
use crate::db::credentials::{CredentialStore, Side};
use crate::error::{Error, Result};
use crate::mount::{self, lock, target};
use crate::network::{self, address};
/// Ergebnis eines Reconcile-Durchlaufs für ein Paar.
#[derive(Debug)]
pub enum Action {
NoOp,
MountedLocal,
MountedCloud,
SwitchedToLocal,
SwitchedToCloud,
Failed(String),
}
#[derive(Debug)]
pub struct ReconcileOutcome {
pub pair_id: String,
pub pair_name: String,
pub action: Action,
}
/// Führt `reconcile_pair` für jedes aktivierte Paar in `cfg` aus.
///
/// Die Erreichbarkeitsprüfungen (Ping/`curl`-Subprozesse, bis zu mehrere Sekunden pro Seite)
/// sind rein lesend und unabhängig voneinander - sie laufen daher parallel über
/// `spawn_blocking` statt sequenziell, damit die Gesamtlaufzeit eines `watch`-Durchlaufs bei
/// vielen/langsamen Paaren nicht linear mit deren Anzahl wächst. Das eigentliche Mounten/
/// Aushängen bleibt dagegen sequenziell: es teilt sich den `CredentialStore` und schreibt
/// Zustand (Symlinks, Credential-/Secrets-Dateien) - die Pro-Paar-Sperre (siehe [`lock`])
/// verhindert zwar Kollisionen, unbegrenzt paralleler DB-/Dateizugriff wäre aber unnötiges
/// 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();
let mut checks = Vec::with_capacity(enabled.len());
for pair in &enabled {
let local = pair.local.clone();
let cloud_host = pair.cloud.host_or_url.clone();
let settings = cfg.settings.clone();
checks.push(tokio::task::spawn_blocking(move || {
let (local_reachable, local_ip) = check_local_reachable(&local, &settings);
let cloud_reachable = network::is_reachable(&cloud_host);
(local_reachable, local_ip, cloud_reachable)
}));
}
let mut outcomes = Vec::with_capacity(enabled.len());
for (pair, check) in enabled.iter().zip(checks) {
let (local_reachable, local_ip, cloud_reachable) =
check.await.unwrap_or((false, None, false));
outcomes.push(
reconcile_pair_checked(
pair,
&cfg.settings,
creds,
local_reachable,
local_ip,
cloud_reachable,
)
.await,
);
}
outcomes
}
/// Entscheidungstabelle (siehe Moduldoku): prüft Erreichbarkeit beider Seiten, vergleicht
/// mit der aktuell aktiven Seite, und schaltet bei Bedarf um.
pub async fn reconcile_pair(
pair: &DrivePair,
settings: &GlobalSettings,
creds: &CredentialStore,
) -> ReconcileOutcome {
let (local_reachable, local_ip) = check_local_reachable(&pair.local, settings);
let cloud_reachable = network::is_reachable(&pair.cloud.host_or_url);
reconcile_pair_checked(
pair,
settings,
creds,
local_reachable,
local_ip,
cloud_reachable,
)
.await
}
async fn reconcile_pair_checked(
pair: &DrivePair,
settings: &GlobalSettings,
creds: &CredentialStore,
local_reachable: bool,
local_ip: Option<Ipv4Addr>,
cloud_reachable: bool,
) -> ReconcileOutcome {
let pair_id = pair.id.clone();
let pair_name = pair.name.clone();
match reconcile_pair_inner(
pair,
settings,
creds,
local_reachable,
local_ip,
cloud_reachable,
)
.await
{
Ok(action) => ReconcileOutcome {
pair_id,
pair_name,
action,
},
Err(e) => ReconcileOutcome {
pair_id,
pair_name,
action: Action::Failed(e.to_string()),
},
}
}
async fn reconcile_pair_inner(
pair: &DrivePair,
settings: &GlobalSettings,
creds: &CredentialStore,
local_reachable: bool,
local_ip: Option<Ipv4Addr>,
cloud_reachable: bool,
) -> Result<Action> {
let _guard = lock::acquire(&pair.id).await?;
let active = target::active_side(pair);
cleanup_orphaned_mounts(pair, settings, active).await;
if local_reachable {
if active == Some(Side::Local) {
return Ok(Action::NoOp);
}
switch_to(pair, settings, Side::Local, creds, active, local_ip).await?;
return Ok(if active.is_some() {
Action::SwitchedToLocal
} else {
Action::MountedLocal
});
}
if cloud_reachable {
if active == Some(Side::Cloud) {
return Ok(Action::NoOp);
}
switch_to(pair, settings, Side::Cloud, creds, active, local_ip).await?;
return Ok(if active.is_some() {
Action::SwitchedToCloud
} else {
Action::MountedCloud
});
}
Ok(Action::NoOp)
}
/// Prüft die Erreichbarkeit der lokalen Seite und gibt dabei - falls erfolgreich aufgelöst -
/// die konkrete IP zurück, damit ein nachfolgender `switch_to(..., Side::Local, ...)` dieselbe
/// MAC-Adresse nicht ein zweites Mal über `mac2ip` auflösen muss.
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),
}
}
/// Räumt einen Mount auf der jeweils NICHT aktiven Seite auf, falls einer besteht.
///
/// [`target::active_side`] identifiziert "die" aktive Seite ausschließlich über den sichtbaren
/// Symlink - ein Mount auf der jeweils anderen Seite (z. B. weil eine vorherige
/// `activate_symlink`-Aktivierung mitten im Umschalten abgebrochen wurde, oder weil extern
/// manuell gemountet wurde) bliebe dadurch unbemerkt: `status`/`watch` sähen ihn nie, und er
/// würde nie automatisch wieder ausgehängt. Best-effort (nur geloggt, nicht propagiert) - ein
/// hier fehlschlagendes Aufräumen darf den eigentlichen Reconcile-Schritt nicht blockieren.
async fn cleanup_orphaned_mounts(
pair: &DrivePair,
settings: &GlobalSettings,
active: Option<Side>,
) {
for side in [Side::Local, Side::Cloud] {
if Some(side) == active {
continue;
}
let dir = target::backing_dir(pair, side);
match mount::state::is_mounted(&dir) {
Ok(true) => {
logger_ctdra::warn(
"reconcile",
&format!(
"pair '{}': found an orphaned mount on the inactive side '{}' (not \
reflected by the active symlink) - unmounting it",
pair.id,
side.as_str()
),
);
if let Err(e) = unmount_side(pair, settings, side).await {
logger_ctdra::warn(
"reconcile",
&format!(
"pair '{}': could not clean up orphaned mount on side '{}': {e}",
pair.id,
side.as_str()
),
);
}
}
Ok(false) => {}
Err(e) => logger_ctdra::warn(
"reconcile",
&format!(
"pair '{}': could not check mount state for side '{}': {e}",
pair.id,
side.as_str()
),
),
}
}
}
/// Mountet `new_side` zuerst, flippt danach den sichtbaren Symlink, und hängt erst zum
/// Schluss `old_active` (falls vorhanden und verschieden) aus. Diese Reihenfolge stellt
/// sicher, dass der sichtbare `pair.mount_point` nie auf ein gerade ausgehängtes oder noch
/// nicht bereites Backing-Verzeichnis zeigt.
async fn switch_to(
pair: &DrivePair,
settings: &GlobalSettings,
new_side: Side,
creds: &CredentialStore,
old_active: Option<Side>,
cached_local_ip: Option<Ipv4Addr>,
) -> Result<()> {
mount_side(pair, settings, new_side, creds, cached_local_ip).await?;
// Falls das Aktivieren des Symlinks fehlschlägt, muss die gerade gemountete `new_side`
// wieder ausgehängt werden, statt sie unbemerkt gemountet zu lassen: der nächste
// Reconcile-Durchlauf sähe sonst weiterhin `old_active` als aktive Seite (der Symlink zeigt
// ja weiter darauf) und würde `new_side` nie wieder aushängen - ein dauerhaft verwaister
// Mount.
if let Err(e) = target::activate_symlink(pair, new_side) {
let _ = unmount_side(pair, settings, new_side).await;
return Err(e);
}
if let Some(old_side) = old_active
&& old_side != new_side
{
unmount_side(pair, settings, old_side).await?;
}
Ok(())
}
async fn mount_side(
pair: &DrivePair,
settings: &GlobalSettings,
side: Side,
creds: &CredentialStore,
cached_local_ip: Option<Ipv4Addr>,
) -> Result<()> {
let mount_target =
target::build_target_with_cached_local_ip(pair, settings, side, cached_local_ip)?;
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
// (anders als CIFS/WebDAV) keine `uid=`/`gid=`-Mount-Option gibt, die den Zugriff nach dem
// Mounten sonst regeln würde (siehe `target::ensure_owner_only_dir`).
if let Some(owner) = &pair.owner_user {
target::ensure_owner_only_dir(&mount_target.mount_point, owner)?;
}
let backend = mount::backend_for(target::side_kind(pair, side));
backend.check_available()?;
let cred = creds.get(&pair.id, side).await?;
backend.prepare(&mount_target, cred.as_ref())?;
backend.mount(&mount_target)
}
/// Hängt aus, was aktuell aktiv ist (siehe [`target::active_side`]), unabhängig von der
/// Erreichbarkeit - für `smart-mount unmount`. Der Symlink bleibt bestehen (zeigt danach auf
/// 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> {
let _guard = lock::acquire(&pair.id).await?;
// Beide Seiten werden unabhängig voneinander versucht - ein Fehler (auch ein
// `is_mounted`-Prüffehler) bei der ersten Seite darf die zweite nicht ungeprüft
// überspringen. Der erste aufgetretene Fehler wird nach beiden Versuchen zurückgegeben,
// damit der Aufrufer weiterhin erkennt, dass etwas fehlgeschlagen ist.
let mut first_err: Option<Error> = None;
for side in [Side::Local, Side::Cloud] {
let dir = target::backing_dir(pair, side);
match mount::state::is_mounted(&dir) {
Ok(true) => {
if let Err(e) = unmount_side(pair, settings, side).await
&& first_err.is_none()
{
first_err = Some(e);
}
}
Ok(false) => {}
Err(e) => {
logger_ctdra::warn(
"reconcile",
&format!(
"pair '{}': could not check mount state for side '{}': {e} - still \
attempting the other side",
pair.id,
side.as_str()
),
);
if first_err.is_none() {
first_err = Some(e);
}
}
}
}
match first_err {
Some(e) => Err(e),
None => Ok(Action::NoOp),
}
}
async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -> Result<()> {
let mount_target = target::build_target(pair, settings, side).unwrap_or_else(|e| {
logger_ctdra::warn(
"reconcile",
&format!(
"pair '{}': could not fully resolve the mount target for side '{}' while \
unmounting ({e}) - unmounting its backing directory anyway with best-effort \
defaults",
pair.id,
side.as_str()
),
);
crate::mount::MountTarget {
pair_id: pair.id.clone(),
side,
source: String::new(),
mount_point: target::backing_dir(pair, side),
options: vec![],
owner_user: pair.owner_user.clone(),
}
});
mount::backend_for(target::side_kind(pair, side)).unmount(&mount_target)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{CloudSide, MountKind};
use std::net::Ipv4Addr;
fn sample_pair(local_kind: MountKind, cloud_kind: MountKind) -> DrivePair {
DrivePair {
id: "pair-1".into(),
name: "Test".into(),
enabled: true,
owner_user: None,
mount_point: "/media/smart-mount/pair-1".into(),
local: LocalSide {
kind: local_kind,
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 10)),
share: "share".into(),
username: None,
extra_options: vec!["vers=3.0".into()],
},
cloud: CloudSide {
kind: cloud_kind,
host_or_url: "https://cloud.example.com/dav".into(),
share: "share".into(),
username: None,
extra_options: vec![],
},
}
}
#[test]
fn local_source_formats_smb_unc_path() {
let pair = sample_pair(MountKind::Smb, MountKind::WebDav);
let source =
target::local_source(&pair.local, &GlobalSettings::default()).expect("local_source");
assert_eq!(source, "//192.168.1.10/share");
}
#[test]
fn build_target_uses_backing_dir_not_visible_mount_point() {
let pair = sample_pair(MountKind::Smb, MountKind::WebDav);
let t =
target::build_target(&pair, &GlobalSettings::default(), Side::Local).expect("build");
assert_ne!(t.mount_point, pair.mount_point);
assert_eq!(t.mount_point, target::backing_dir(&pair, Side::Local));
}
#[tokio::test]
async fn unmount_pair_noop_when_neither_side_mounted() {
let pair = sample_pair(MountKind::Smb, MountKind::WebDav);
let action = unmount_pair(&pair, &GlobalSettings::default())
.await
.expect("unmount");
assert!(matches!(action, Action::NoOp));
}
}
-62
View File
@@ -1,62 +0,0 @@
use std::env;
use std::process::Command;
/// Prüft, ob das Programm mit Root-/Administrator-Rechten ausgeführt wird.
///
/// # Returns
///
/// * `true` - Das Programm läuft mit erhöhten Rechten
/// * `false` - Das Programm läuft mit normalen Benutzerrechten
pub fn is_run_as_root() -> bool {
#[cfg(any(target_os = "windows"))]
{
// Windows: Prüfen ob Admin-Rechte vorhanden
Command::new("net")
.args(&["session"])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
}
#[cfg(not(any(target_os = "windows")))]
{
// Linux/Unix: Prüfen ob Root oder sudo
unsafe { libc::geteuid() == 0 }
}
}
/// Startet das Programm mit Root-/Administrator-Rechten neu.
///
/// Diese Funktion versucht das Programm mit erhöhten Rechten neu zu starten.
///
/// # Details
///
/// - Unter Linux wird das Programm mit `sudo` neu gestartet
/// - Unter Windows wird der UAC-Dialog zur Rechteanfrage angezeigt
pub fn run_as_root() {
#[cfg(any(target_os = "windows"))]
{
let commandline_args: Vec<String> = env::args().collect();
let program_path = commandline_args[0].clone();
Command::new("powershell")
.args(&[
"Start-Process",
&program_path,
"-ArgumentList",
&commandline_args[1..].join(" "),
"-Verb",
"RunAs"
])
.exec();
}
#[cfg(not(any(target_os = "windows")))]
{
use std::os::unix::process::CommandExt;
let commandline_args: Vec<String> = env::args().collect();
let _output = Command::new("sudo")
.args(&commandline_args)
.exec(); // Bye bye never returns
}
}
+170
View File
@@ -0,0 +1,170 @@
//! Cron-Fallback (`smart-mount service crontab`) 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
//! [`crate::cli::require_root`]); eine persönliche Nutzer-Crontab liefe ins Leere.
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
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())
}
/// Ob `systemctl` auf diesem System vorhanden ist - rein informativ für `doctor`; die
/// eigentliche systemd-Einrichtung läuft über die Paketierung (siehe Moduldoku).
pub fn is_available() -> bool {
crate::mount::binary_available("systemctl")
}
/// 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`.
pub fn crontab_equivalent(watch_interval_secs: u64) -> String {
let bin = binary_path();
let schedule = cron_schedule_for_interval(watch_interval_secs);
format!("@reboot {bin} mount --all\n{schedule} {bin} watch\n")
}
/// Ergebnis von [`install_cron`].
pub enum CronInstallOutcome {
/// Systemweiter Eintrag geschrieben (`/etc/cron.d/smart-mount`).
SystemFile(PathBuf),
/// Kein Cron-Mechanismus auf diesem System gefunden - nichts geschrieben, der Aufrufer
/// sollte stattdessen [`crontab_equivalent`] anzeigen.
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.
pub fn install_cron(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
if !Path::new("/etc/cron.d").is_dir() {
return Ok(CronInstallOutcome::Unavailable);
}
let contents = managed_cron_block(watch_interval_secs);
std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(CRON_D_PATH, std::fs::Permissions::from_mode(0o644))
.map_err(|e| Error::io(CRON_D_PATH, e))?;
}
Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH)))
}
fn managed_cron_block(watch_interval_secs: u64) -> String {
let bin = binary_path();
let schedule = cron_schedule_for_interval(watch_interval_secs);
// /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root,
// passend dazu, dass Mounts auch sonst immer als root laufen.
format!(
"{CRON_BEGIN_MARKER}\n@reboot root {bin} mount --all\n{schedule} root {bin} watch\n{CRON_END_MARKER}\n"
)
}
/// Ergebnis von [`uninstall_cron`].
pub enum CronUninstallOutcome {
/// Ein verwalteter Cron-Eintrag wurde gefunden und entfernt.
Removed,
/// Kein von smart-mount verwalteter Cron-Eintrag vorhanden - nichts zu tun.
NotPresent,
}
/// Gegenstück zu [`install_cron`]: entfernt einen zuvor über `install_cron` angelegten
/// Cron-Eintrag wieder, sofern vorhanden.
pub fn uninstall_cron() -> Result<CronUninstallOutcome> {
let path = Path::new(CRON_D_PATH);
if !path.exists() {
return Ok(CronUninstallOutcome::NotPresent);
}
std::fs::remove_file(path).map_err(|e| Error::io(CRON_D_PATH, e))?;
Ok(CronUninstallOutcome::Removed)
}
/// Rechnet ein Sekunden-Intervall in einen `*/N`-artigen Cron-Ausdruck um. Crons Granularität
/// ist Minuten (keine Sekunden) - es wird auf die nächste Minute gerundet, mindestens 1
/// (Cron kann nicht häufiger als minütlich auslösen). Ab 60 Minuten wird auf Stunden
/// umgestellt (`0 */N * * *`); Intervalle über 24h werden grob als "täglich um Mitternacht"
/// angenähert, da ein reiner `*/N`-Ausdruck das nicht mehr sauber abbilden kann.
fn cron_schedule_for_interval(interval_secs: u64) -> String {
let minutes = ((interval_secs as f64 / 60.0).round() as u64).max(1);
if minutes <= 59 {
format!("*/{minutes} * * * *")
} else {
let hours = ((minutes as f64 / 60.0).round() as u64).max(1);
if hours <= 23 {
format!("0 */{hours} * * *")
} else {
"0 0 * * *".to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn managed_cron_block_includes_root_user_field_and_both_lines() {
let block = managed_cron_block(120);
assert!(block.starts_with(CRON_BEGIN_MARKER));
assert!(block.trim_end().ends_with(CRON_END_MARKER));
assert!(block.contains("@reboot root "));
assert!(block.contains("mount --all"));
assert!(block.contains("*/2 * * * * root "));
}
#[test]
fn default_watch_interval_maps_to_every_two_minutes() {
assert_eq!(cron_schedule_for_interval(120), "*/2 * * * *");
}
#[test]
fn rounds_to_the_nearest_minute() {
assert_eq!(cron_schedule_for_interval(90), "*/2 * * * *");
assert_eq!(cron_schedule_for_interval(80), "*/1 * * * *");
}
#[test]
fn sub_minute_intervals_clamp_to_one_minute() {
assert_eq!(cron_schedule_for_interval(30), "*/1 * * * *");
assert_eq!(cron_schedule_for_interval(1), "*/1 * * * *");
}
#[test]
fn switches_to_hourly_expression_above_59_minutes() {
assert_eq!(cron_schedule_for_interval(60 * 90), "0 */2 * * *");
}
#[test]
fn very_long_intervals_fall_back_to_daily_at_midnight() {
assert_eq!(cron_schedule_for_interval(60 * 60 * 30), "0 0 * * *");
}
#[test]
fn crontab_equivalent_includes_both_reboot_and_watch_lines() {
let output = crontab_equivalent(120);
assert!(output.contains("@reboot"));
assert!(output.contains("mount --all"));
assert!(output.contains("*/2 * * * *"));
assert!(output.contains("watch"));
}
}