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>
This commit is contained in:
2026-09-20 13:32:02 +02:00
co-authored by Claude Sonnet 5
parent 17c1f6d2ba
commit c6526e2130
30 changed files with 450 additions and 2526 deletions
Generated
-632
View File
@@ -178,17 +178,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "apple-native-keyring-store"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a"
dependencies = [
"keyring-core",
"log",
"security-framework",
]
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.2" version = "1.9.2"
@@ -224,143 +213,6 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8" checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8"
[[package]]
name = "async-broadcast"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
dependencies = [
"event-listener",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-channel"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
dependencies = [
"concurrent-queue",
"event-listener-strategy",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-executor"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
dependencies = [
"async-task",
"concurrent-queue",
"fastrand",
"futures-lite",
"pin-project-lite",
"slab",
]
[[package]]
name = "async-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
dependencies = [
"autocfg",
"cfg-if",
"concurrent-queue",
"futures-io",
"futures-lite",
"parking",
"polling",
"rustix 1.1.4",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-lock"
version = "3.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
dependencies = [
"event-listener",
"event-listener-strategy",
"pin-project-lite",
]
[[package]]
name = "async-process"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
dependencies = [
"async-channel",
"async-io",
"async-lock",
"async-signal",
"async-task",
"blocking",
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.4",
]
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-signal"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
dependencies = [
"async-io",
"async-lock",
"atomic-waker",
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.4",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
]
[[package]]
name = "async-task"
version = "4.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.1" version = "1.5.1"
@@ -436,28 +288,6 @@ dependencies = [
"hybrid-array", "hybrid-array",
] ]
[[package]]
name = "block-padding"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
dependencies = [
"hybrid-array",
]
[[package]]
name = "blocking"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
"async-channel",
"async-task",
"futures-io",
"futures-lite",
"piper",
]
[[package]] [[package]]
name = "branches" name = "branches"
version = "0.4.6" version = "0.4.6"
@@ -505,15 +335,6 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cbc"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
dependencies = [
"cipher 0.5.2",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.4.6" version = "1.4.6"
@@ -714,22 +535,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc 0.2.189",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -860,18 +665,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
]
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.7" version = "0.2.7"
@@ -895,33 +688,6 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "endi"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enumflags2"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
dependencies = [
"enumflags2_derive",
"serde",
]
[[package]]
name = "enumflags2_derive"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "env_filter" name = "env_filter"
version = "2.0.0" version = "2.0.0"
@@ -969,26 +735,6 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "event-listener"
version = "5.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
dependencies = [
"parking",
"pin-project-lite",
]
[[package]]
name = "event-listener-strategy"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
dependencies = [
"event-listener",
"pin-project-lite",
]
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -1030,36 +776,6 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-io"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
[[package]]
name = "futures-lite"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
"fastrand",
"futures-core",
"futures-io",
"parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.34" version = "0.3.34"
@@ -1073,7 +789,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-macro",
"futures-task", "futures-task",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
@@ -1203,24 +918,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
dependencies = [
"hmac",
]
[[package]]
name = "hmac"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "home" name = "home"
version = "0.5.12" version = "0.5.12"
@@ -1453,7 +1150,6 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [ dependencies = [
"block-padding",
"hybrid-array", "hybrid-array",
] ]
@@ -1518,27 +1214,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "keyring"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627"
dependencies = [
"apple-native-keyring-store",
"keyring-core",
"windows-native-keyring-store",
"zbus-secret-service-keyring-store",
]
[[package]]
name = "keyring-core"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d"
dependencies = [
"log",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -1755,20 +1430,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.8" version = "0.4.8"
@@ -1779,15 +1440,6 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -1803,27 +1455,6 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1860,16 +1491,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "ordered-stream"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
dependencies = [
"futures-core",
"pin-project-lite",
]
[[package]] [[package]]
name = "owo-colors" name = "owo-colors"
version = "3.5.0" version = "3.5.0"
@@ -1885,12 +1506,6 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
[[package]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1926,17 +1541,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "piper"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
dependencies = [
"atomic-waker",
"fastrand",
"futures-io",
]
[[package]] [[package]]
name = "polling" name = "polling"
version = "3.11.0" version = "3.11.0"
@@ -2010,15 +1614,6 @@ dependencies = [
"syn 2.0.119", "syn 2.0.119",
] ]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.107" version = "1.0.107"
@@ -2307,48 +1902,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "secret-service"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8"
dependencies = [
"aes 0.9.3",
"cbc",
"futures-util",
"getrandom 0.4.3",
"hkdf",
"hybrid-array",
"num",
"once_cell",
"serde",
"sha2",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags",
"core-foundation",
"core-foundation-sys",
"libc 0.2.189",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc 0.2.189",
]
[[package]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@@ -2398,17 +1951,6 @@ dependencies = [
"zmij", "zmij",
] ]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
]
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "1.1.1"
@@ -2424,17 +1966,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
"digest",
]
[[package]] [[package]]
name = "sharded-slab" name = "sharded-slab"
version = "0.1.7" version = "0.1.7"
@@ -2530,7 +2061,6 @@ dependencies = [
"config-ctdra", "config-ctdra",
"dialoguer", "dialoguer",
"getrandom 0.4.3", "getrandom 0.4.3",
"keyring",
"logger-ctdra", "logger-ctdra",
"program-ctdra", "program-ctdra",
"serde", "serde",
@@ -2803,18 +2333,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "toml_edit"
version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"winnow 1.0.4",
]
[[package]] [[package]]
name = "toml_parser" name = "toml_parser"
version = "1.1.3+spec-1.1.0" version = "1.1.3+spec-1.1.0"
@@ -3111,17 +2629,6 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "uds_windows"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "uncased" name = "uncased"
version = "0.9.10" version = "0.9.10"
@@ -3195,7 +2702,6 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"js-sys", "js-sys",
"serde_core",
"sha1_smol", "sha1_smol",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -3325,19 +2831,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-native-keyring-store"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8"
dependencies = [
"byteorder",
"keyring-core",
"regex",
"windows-sys 0.61.2",
"zeroize",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@@ -3449,9 +2942,6 @@ name = "winnow"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
@@ -3503,87 +2993,6 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zbus"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
"async-broadcast",
"async-executor",
"async-io",
"async-lock",
"async-process",
"async-recursion",
"async-task",
"async-trait",
"blocking",
"enumflags2",
"event-listener",
"futures-core",
"futures-lite",
"hex",
"libc 0.2.189",
"ordered-stream",
"rustix 1.1.4",
"serde",
"serde_repr",
"tracing",
"uds_windows",
"uuid",
"windows-sys 0.61.2",
"winnow 1.0.4",
"zbus_macros",
"zbus_names",
"zvariant",
]
[[package]]
name = "zbus-secret-service-keyring-store"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a"
dependencies = [
"keyring-core",
"secret-service",
"zbus",
]
[[package]]
name = "zbus_macros"
version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.5",
"zbus_names",
"zvariant",
"zvariant_utils",
]
[[package]]
name = "zbus_names"
version = "4.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
dependencies = [
"serde",
"winnow 1.0.4",
"zvariant",
]
[[package]]
name = "zcheapstr"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.57" version = "0.8.57"
@@ -3671,44 +3080,3 @@ name = "zmij"
version = "1.0.23" version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zvariant"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
dependencies = [
"endi",
"enumflags2",
"serde",
"winnow 1.0.4",
"zcheapstr",
"zvariant_derive",
"zvariant_utils",
]
[[package]]
name = "zvariant_derive"
version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
dependencies = [
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.5",
"zvariant_utils",
]
[[package]]
name = "zvariant_utils"
version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 3.0.5",
"winnow 1.0.4",
]
+24 -2
View File
@@ -26,7 +26,6 @@ serde_json = "1"
thiserror = "2" thiserror = "2"
anyhow = "1" anyhow = "1"
aes-gcm = "0.11.1" aes-gcm = "0.11.1"
keyring = "4.2.0"
dialoguer = "0.12.0" dialoguer = "0.12.0"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
# Eigene, direkte Zufallsquelle für Nonce-/Schlüssel-Generierung, entkoppelt von aes-gcms # Eigene, direkte Zufallsquelle für Nonce-/Schlüssel-Generierung, entkoppelt von aes-gcms
@@ -58,12 +57,17 @@ smart-mount bindet Paare aus einem lokalen (LAN, WebDAV/SMB/NFS) und einem Cloud
ein: ist das lokale Laufwerk erreichbar, wird es gemountet, sonst automatisch das 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 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 zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen Turso-Datenbank
gespeichert; smart-mount kann als root/System-Dienst und als Nutzer-Dienst laufen.\ 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 = [ assets = [
["target/release/smart-mount", "usr/bin/smart-mount", "755"], ["target/release/smart-mount", "usr/bin/smart-mount", "755"],
["README.md", "usr/share/doc/smart-mount/README.md", "644"], ["README.md", "usr/share/doc/smart-mount/README.md", "644"],
["LICENSE", "usr/share/doc/smart-mount/copyright", "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] [package.metadata.generate-rpm]
@@ -71,9 +75,27 @@ assets = [
{ source = "target/release/smart-mount", dest = "/usr/bin/smart-mount", mode = "755" }, { 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 = "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 = "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" = "*" } requires = { "mac2ip" = "*", "nmap" = "*" }
suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" } 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] [package.metadata.arch]
pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben
+94 -110
View File
@@ -24,28 +24,31 @@ werden (z. B. über ein separates Sync-Tool).
- `davfs2` für WebDAV-Laufwerke - `davfs2` für WebDAV-Laufwerke
- `cifs-utils` für SMB/CIFS-Laufwerke - `cifs-utils` für SMB/CIFS-Laufwerke
- `nfs-common` (Debian/Ubuntu) bzw. `nfs-utils` (Fedora/Arch) für NFS-Laufwerke - `nfs-common` (Debian/Ubuntu) bzw. `nfs-utils` (Fedora/Arch) für NFS-Laufwerke
- Für MAC-basierte lokale Laufwerke im **Nutzerkontext**: `mac2ip` löst über `ip neigh` auf, - `systemd`: smart-mount richtet sich beim Installieren des Pakets automatisch als
ohne dafür Root-Rechte zu benötigen - nur der letzte Fallback-Schritt (ein aktiver System-Dienst ein (siehe "Automatischer Start beim Systemstart" unten). Ohne systemd bleibt
`nmap`-Scan, falls das Zielgerät nicht in der ARP-Nachbartabelle steht) braucht `smart-mount service crontab` als manueller Fallback.
passwortlosen `sudo`-Zugriff auf `nmap`. Ohne das schlägt die Auflösung in diesem Fall sauber
fehl (kein Absturz) - entweder passwortlosen `sudo` für `nmap` einrichten, oder das
entsprechende Laufwerkspaar im System-/root-Kontext betreiben.
--- ---
## CLI-Nutzung ## 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 ```bash
# Neues Laufwerkspaar interaktiv anlegen (fragt Name, Kontext, Mount-Typ, Adresse, # Neues Laufwerkspaar interaktiv anlegen (fragt Name, Mount-Typ, Adresse, Freigabe und ggf.
# Freigabe und ggf. Zugangsdaten ab) # Zugangsdaten ab)
smart-mount drive add sudo smart-mount drive add
# Nicht-interaktiv per Flags (für Skripte/Automatisierung) - fehlende Pflichtfelder sind # Nicht-interaktiv per Flags (für Skripte/Automatisierung) - fehlende Pflichtfelder sind
# dann ein Fehler statt eines (ohne Terminal ohnehin unmöglichen) Prompts. Jedes # 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 # *-password-stdin-Flag liest genau eine Zeile von stdin (bei mehreren im selben Aufruf
# entsprechend mehrere Zeilen, eine pro Flag in Reihenfolge): # entsprechend mehrere Zeilen, eine pro Flag in Reihenfolge):
smart-mount drive add --non-interactive \ sudo smart-mount drive add --non-interactive \
--name NAS --context system \ --name NAS \
--local-kind smb --local-ip 192.168.1.50 --local-share share --local-username nasuser --local-password-stdin \ --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 \ --cloud-kind webdav --cloud-host https://cloud.example.com/dav --cloud-share / --cloud-username clouduser --cloud-password cloud-pw-direkt \
<<< "lokales-passwort" <<< "lokales-passwort"
@@ -55,35 +58,33 @@ smart-mount drive add --non-interactive \
# Bestehendes Laufwerkspaar bearbeiten - nur angegebene Felder ändern sich, alles andere # Bestehendes Laufwerkspaar bearbeiten - nur angegebene Felder ändern sich, alles andere
# (inkl. gespeichertem Passwort) bleibt unangetastet. Ohne Flags: interaktiver Durchlauf, # (inkl. gespeichertem Passwort) bleibt unangetastet. Ohne Flags: interaktiver Durchlauf,
# vorbelegt mit den aktuellen Werten (inkl. Rückfrage, ob das Passwort geändert werden soll). # vorbelegt mit den aktuellen Werten (inkl. Rückfrage, ob das Passwort geändert werden soll).
smart-mount drive edit <id> --name "Neuer Name" --local-ip 192.168.1.99 sudo smart-mount drive edit <id> --name "Neuer Name" --local-ip 192.168.1.99
smart-mount drive edit <id> --non-interactive --local-password-stdin <<< "neues-passwort" sudo smart-mount drive edit <id> --non-interactive --local-password-stdin <<< "neues-passwort"
# Konfigurierte Laufwerkspaare auflisten / entfernen (--json für Skripte) # Konfigurierte Laufwerkspaare auflisten / entfernen (--json für Skripte)
smart-mount drive list smart-mount drive list
smart-mount drive list --json smart-mount drive list --json
smart-mount drive remove <id> sudo smart-mount drive remove <id>
# Ein einzelnes Paar oder alle einbinden/aushängen # Ein einzelnes Paar oder alle einbinden/aushängen
smart-mount mount --name <id> sudo smart-mount mount --name <id>
smart-mount mount --all sudo smart-mount mount --all
smart-mount unmount --all sudo smart-mount unmount --all
# Aktuellen Status (Mount-Zustand + Erreichbarkeit beider Seiten) anzeigen (--json für Skripte) # Aktuellen Status (Mount-Zustand + Erreichbarkeit beider Seiten) anzeigen (--json für Skripte)
smart-mount status smart-mount status
smart-mount status --json smart-mount status --json
# Ein Reconcile-Durchlauf (lokal/Cloud-Umschaltung) - für systemd-Timer/Cron gedacht # Ein Reconcile-Durchlauf (lokal/Cloud-Umschaltung) - für systemd-Timer/Cron gedacht
smart-mount watch sudo smart-mount watch
# systemd-Units installieren (System- bzw. Nutzerkontext) # Cron-Fallback für Systeme ohne (genutzten) systemd - siehe "Automatischer Start beim
sudo smart-mount service install --system # Systemstart" unten
smart-mount service install --user sudo smart-mount service crontab
sudo smart-mount service crontab --remove
# Einmaliges root-Setup, damit Nutzer-Kontext-Paare unprivilegiert (un)gemountet werden können # Voraussetzungen prüfen (Binaries, Scheduler) - deckt gebündelt ab, was man sonst erst
sudo smart-mount setup fstab # einzeln beim Mount-Fehlschlag entdecken würde
# Voraussetzungen prüfen (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) - deckt
# gebündelt ab, was man sonst erst einzeln beim Mount-Fehlschlag entdecken würde
smart-mount doctor smart-mount doctor
smart-mount doctor --json smart-mount doctor --json
@@ -92,27 +93,22 @@ smart-mount completions bash > /etc/bash_completion.d/smart-mount
smart-mount completions zsh > "${fpath[1]}/_smart-mount" smart-mount completions zsh > "${fpath[1]}/_smart-mount"
``` ```
Die Konfiguration liegt unter `~/.config/smart-mount/config.toml` (Nutzerkontext) bzw. Die Konfiguration liegt immer unter `/etc/smart-mount/config.toml`, unabhängig davon, ob
`/etc/smart-mount/config.toml` (root/System-Kontext) - automatisch aufgelöst je nachdem, ob `smart-mount` selbst mit oder ohne Root-Rechte aufgerufen wird (siehe oben). Die verschlüsselte
`smart-mount` mit Root-Rechten läuft. Die verschlüsselte Zugangsdaten-Datenbank Zugangsdaten-Datenbank (`smart-mount.db`) liegt im selben Verzeichnis.
(`smart-mount.db`) liegt im selben Verzeichnis.
### Wo die Laufwerke eingebunden werden ### Wo die Laufwerke eingebunden werden
Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir` Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir`
(`<mount_base_dir>/<pair-id>`) - mehrere Paare stören sich also nie gegenseitig. Standardwert (`<mount_base_dir>/<pair-id>`) - mehrere Paare stören sich also nie gegenseitig. Standardwert:
für `mount_base_dir`: `/run/media/smart-mount` im System-Kontext (root, ein gemeinsamer, `/run/media/smart-mount`, ein einzelner, flacher Namensraum (smart-mount mountet immer als
keinem Nutzer zugeordneter Namensraum), `/run/media/<Nutzer>/smart-mount` im Nutzerkontext - root, siehe oben) - `/run/media` ist auf den meisten Systemen bereits die übliche Konvention
`/run/media` ist auf den meisten Systemen bereits die übliche Konvention für eingebundene für eingebundene Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`,
Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie muss also nie persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden
persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden bei jedem bei jedem `mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen. Ist für ein Paar
`mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen - eigenes Anlegen ist nicht nötig. `owner_user` gesetzt, bekommt genau dieser Nutzer über die Mount-Optionen (`uid=`/`gid=`, siehe
"Architekturentscheidungen" unten) vollen Zugriff auf den Inhalt - die Trennung passiert also
**Achtung bei Nutzer-Kontext-Paaren:** `/run/media` gehört standardmäßig `root:root` mit Modus über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer.
`0755` - ein normaler Nutzer kann dort also nicht einmal sein eigenes Unterverzeichnis
anlegen. `sudo smart-mount setup fstab` übernimmt das einmalig (legt `/run/media/<Nutzer>`
sowie `/run/media/<Nutzer>/smart-mount` an und macht den Nutzer zum Besitzer) - ohne diesen
Schritt schlägt das automatische Anlegen für Nutzer-Kontext-Paare fehl.
Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit
auf einen beliebigen anderen Pfad ändern. auf einen beliebigen anderen Pfad ändern.
@@ -121,45 +117,50 @@ auf einen beliebigen anderen Pfad ändern.
## Automatischer Start beim Systemstart ## 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 ```bash
sudo smart-mount setup fstab # einmalig, nur nötig bei Nutzer-Kontext-Paaren sudo systemctl edit smart-mount-watch.timer
sudo smart-mount service install --system # für System-Kontext-Paare # [Timer]
smart-mount service install --user # für die eigenen Nutzer-Kontext-Paare # OnUnitActiveSec=60s
``` ```
`service install` wählt automatisch den passenden Mechanismus: ist `systemctl` vorhanden, ### Cron-Fallback (Systeme ohne systemd)
werden systemd-Units installiert (ein `oneshot`-Service für den initialen Mount beim Boot
sowie ein Timer, der periodisch `smart-mount watch` aufruft - Intervall: ```bash
`settings.watch_interval_secs`, Standard 120s). Ist kein systemd vorhanden, wird automatisch sudo smart-mount service crontab # einrichten
auf Cron ausgewichen - als root wird `/etc/cron.d/smart-mount` geschrieben, als normaler sudo smart-mount service crontab --remove # wieder entfernen
Nutzer die eigene, persönliche Crontab über `crontab -l`/`crontab -` aktualisiert (ein ```
verwalteter Block lässt dabei bereits vorhandene, unabhängige Cron-Einträge unangetastet und
verhindert Duplikate bei wiederholten Aufrufen). Ist weder systemd noch Cron vorhanden, werden Erfordert Root (wie `mount`/`watch` - Mounten läuft immer als root, siehe oben) und schreibt
stattdessen die beiden äquivalenten Zeilen zum manuellen Eintragen ausgegeben: `/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 ```cron
@reboot smart-mount mount --all @reboot smart-mount mount --all
*/2 * * * * smart-mount watch */2 * * * * smart-mount watch
``` ```
`smart-mount service crontab` erzwingt gezielt den Cron-Weg (z. B. um systemd bewusst zu
umgehen), mit identischem Verhalten wie der automatische Fallback von `install`.
```bash
sudo smart-mount service uninstall --system
smart-mount service uninstall --user
```
räumt alles wieder auf, was `install`/`crontab`/`setup fstab` eingerichtet haben - systemd-
Units (falls vorhanden), den Cron-Eintrag (falls vorhanden) und - nur bei `--system`, da
`setup fstab` root-weit für alle Nutzer-Kontext-Paare gilt - den verwalteten `/etc/fstab`-
Block. Jeder Teil wird unabhängig geprüft: fehlt etwas (z. B. weil nur Cron statt systemd
installiert war), wird das ohne Fehler übersprungen; die Ausgabe listet, was tatsächlich
entfernt wurde. Backing-Verzeichnisse, gemountete Daten und Gruppenmitgliedschaften (z. B. in
der `davfs2`-Gruppe) werden dabei bewusst **nicht** angerührt - dafür gibt es keine
automatische Umkehrung, da das ungewollte Nebenwirkungen haben könnte (siehe
Architekturentscheidungen unten).
--- ---
## Architekturentscheidungen ## Architekturentscheidungen
@@ -189,39 +190,20 @@ Architekturentscheidungen unten).
beim Umschalten zuerst die neue Seite gemountet und der Symlink umgebogen wird, bevor die 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 alte Seite (mit eben diesem tolerierten Timeout) ausgehängt wird, blockiert ein
hängender Alt-Mount den sichtbaren Wechsel ohnehin nicht. hängender Alt-Mount den sichtbaren Wechsel ohnehin nicht.
- **Privilegienmodell für Nutzer-Mounts**: `sudo smart-mount setup fstab` schreibt einmalig - **Privilegienmodell**: SmartMount läuft ausschließlich als root/System-Dienst - es gibt
`/etc/fstab`-Einträge mit `user,exec,noauto` sowie nötige Gruppenmitgliedschaften (z. B. keinen unprivilegierten Mount-Weg mehr (früher: `/etc/fstab`-Einträge mit `user,noauto`, über
`davfs2`-Gruppe). Jede Seite (lokal/Cloud) bekommt dabei ihr **eigenes, eindeutiges** die ein einzelner Nutzer unprivilegiert selbst mounten konnte). Root mountet direkt
verstecktes Backing-Verzeichnis (nicht denselben Mountpoint für beide) - das entspricht (`mount -t <typ> <quelle> <ziel> -o <optionen>`) für jedes konfigurierte Paar, unabhängig
exakt dem einzigen in `man 8 mount` ("Non-superuser mounts") dokumentierten Fall, statt sich davon, ob/für welchen Nutzer `owner_user` gesetzt ist - siehe `owner_user` unten für die
auf unspezifiziertes Verhalten bei zwei Zeilen mit demselben Ziel zu verlassen. Der Zugriffssteuerung. Das vereinfacht das Rechtemodell erheblich und schließt nebenbei die
konfigurierte, sichtbare Mountpoint selbst ist ein Symlink, den SmartMount zur Laufzeit frühere strukturelle CIFS-Lücke (jeder unprivilegiert mount-berechtigte Nutzer konnte zuvor
zwischen den beiden Backing-Verzeichnissen umschaltet. `exec` wird explizit gesetzt, weil die jedes CIFS-Nutzer-Kontext-Paar mounten, nicht nur sein eigenes) - Zugangsdaten sind jetzt nur
`user`-Option laut `man 8 mount` sonst für jedes Dateisystem automatisch `noexec` erzwingt - noch root zugänglich.
ohne das könnten auf einem Nutzer-Kontext-Laufwerk liegende Skripte nicht ausgeführt werden.
- **Voller Zugriff für einen bestimmten Nutzer (`owner_user`)**: Bei CIFS/WebDAV (keine - **Voller Zugriff für einen bestimmten Nutzer (`owner_user`)**: Bei CIFS/WebDAV (keine
nativen Unix-Rechte) setzt SmartMount automatisch `uid=`/`gid=`/`file_mode=0700`/ nativen Unix-Rechte) setzt SmartMount automatisch `uid=`/`gid=`/`file_mode=0700`/
`dir_mode=0700`, sobald ein Paar ein `owner_user` hat (Pflicht bei Nutzer-Kontext-Paaren, `dir_mode=0700`, sobald ein Paar ein `owner_user` hat - optional, ohne `owner_user` gehört
optional bei System-Kontext). Das ist nicht nur für vollen Zugriff (inkl. Skript-Ausführung) der Mount root. Bei NFS gibt es keine clientseitige `uid=`/`gid=`-Option, Zugriff bestimmt
nötig, sondern bei WebDAV auch sicherheitsrelevant: `mount.davfs` erlaubt einem dort ausschließlich der Server über die tatsächlichen Datei-Eigentümer/-Rechte des Exports.
unprivilegierten Nutzer das Mounten einer `user`-Zeile laut `man mount.davfs` nur, wenn
`uid=` auf ihn selbst zeigt - ohne das dürfte jedes Mitglied der Gruppe `davfs2` jedes
konfigurierte Paar mounten, nicht nur sein eigenes (`setup fstab` verweigert daher Paare ohne
`owner_user`).
**Bekannte, bewusst nicht behobene Lücke bei CIFS im Nutzerkontext:** `man mount.cifs`
bestätigt, dass `uid=`/`gid=` dort **ausschließlich** die simulierte Datei-Ownership nach dem
Mount betreffen - anders als bei davfs2 gibt es **keinen** Mechanismus, der das Mount-*Recht*
einer `user`-fstab-Zeile auf eine bestimmte Person einschränkt. Jeder lokale Nutzer, der
unprivilegiert mounten darf, kann daher aktuell jedes konfigurierte CIFS-Nutzer-Kontext-Paar
mounten (nicht nur sein eigenes) und dabei dessen gespeicherte Zugangsdaten für die Dauer des
Mounts mitbenutzen. Das ist eine strukturelle Grenze von `mount(8)`/`mount.cifs`, keine Lücke,
die sich über Mount-Optionen schließen ließe. **Konsequenz:** CIFS-Nutzer-Kontext-Paare nur
auf Einzelnutzer-Maschinen oder unter sich gegenseitig bereits vertrauenden lokalen Nutzern
einsetzen. davfs2 ist von diesem Problem nicht betroffen (siehe oben); 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 - wer die Freigabe mounten kann, sieht
dadurch nicht automatisch fremde Daten.
- **davfs2-Konfiguration**: SmartMount setzt in `davfs2.conf` automatisch `gui_optimize 1` - **davfs2-Konfiguration**: SmartMount setzt in `davfs2.conf` automatisch `gui_optimize 1`
(bündelt PROPFIND-Anfragen, wichtig für grafische Dateimanager) sowie `buf_size 16384` (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 (deutlich über dem Standard von 16 KiB) - Letzteres behebt ein bekanntes Praxisproblem, bei
@@ -230,10 +212,9 @@ Architekturentscheidungen unten).
kleinem `buf_size` stillschweigend abgeschnitten). kleinem `buf_size` stillschweigend abgeschnitten).
- **Zugangsdaten-Verschlüsselung**: Turso hat aktuell keine produktionsreife eingebaute - **Zugangsdaten-Verschlüsselung**: Turso hat aktuell keine produktionsreife eingebaute
Verschlüsselung, daher verschlüsselt SmartMount Passwörter selbst (AES-256-GCM) vor der Verschlüsselung, daher verschlüsselt SmartMount Passwörter selbst (AES-256-GCM) vor der
Ablage. Der Master-Schlüssel wird bevorzugt im OS-Keyring (GNOME Keyring/KWallet über Ablage. Der Master-Schlüssel liegt in einer Schlüsseldatei (`chmod 600`, neben der
secret-service) abgelegt; ist keins verfügbar (typisch für den root/System-Dienst sowie Konfiguration, also `/etc/smart-mount/master.key`) - kein OS-Keyring, da SmartMount
Headless-Systeme), wird automatisch auf eine Schlüsseldatei (`chmod 600`, neben der ausschließlich als root/System-Dienst läuft, für den es kein Nutzer-Keyring gibt.
Konfiguration) zurückgegriffen.
- **`mac2ip`-Integration**: SmartMount ruft `mac2ip --json --auto-trust-networks <mac>` auf. - **`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?"- `--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 Rückfrage automatisch bejahen und dauerhaft in seiner eigenen Cache-Datenbank merken - das
@@ -249,6 +230,10 @@ Architekturentscheidungen unten).
│ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry) │ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry)
├── .gitea/ ├── .gitea/
│ └── workflows/ # CI/CD-Pipelines (Build, Tests, Security-Scans, Releases) │ └── 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/ ├── scripts/
│ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer │ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer
│ ├── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen │ ├── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen
@@ -263,8 +248,7 @@ Architekturentscheidungen unten).
│ ├── mount/ # WebDAV/SMB/NFS-Backends, dynamischer Dispatch │ ├── mount/ # WebDAV/SMB/NFS-Backends, dynamischer Dispatch
│ ├── network/ # Erreichbarkeit, mac2ip-Integration │ ├── network/ # Erreichbarkeit, mac2ip-Integration
│ ├── reconcile/ # Watchdog-Entscheidungslogik (lokal/Cloud-Umschaltung) │ ├── reconcile/ # Watchdog-Entscheidungslogik (lokal/Cloud-Umschaltung)
── systemd/ # systemd-Unit-Generierung/-Installation ── systemd/ # Cron-Fallback (Systeme ohne systemd)
│ └── fstab/ # Einmaliges root-Setup für Nutzer-Mounts
├── tests/ # Cross-Modul-Integrationstests ├── tests/ # Cross-Modul-Integrationstests
├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch) ├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch)
├── LICENSE # Lizenzdatei (GPL-3.0-or-later) ├── LICENSE # Lizenzdatei (GPL-3.0-or-later)
+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
+67 -1
View File
@@ -45,6 +45,52 @@ def resolve_pkgrel(version=None, default="1"):
return str(default) 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): def build_package(target_triple=None, target_arch=None, pkgrel=None):
metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"])) metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"]))
pkg = metadata["packages"][0] pkg = metadata["packages"][0]
@@ -102,6 +148,17 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None):
if os.path.exists("README.md"): if os.path.exists("README.md"):
subprocess.run(["install", "-m", "644", "README.md", f"{doc_dir}/README.md"], check=True) 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] installed_size = subprocess.check_output(["du", "-sb", build_dir]).decode().split()[0]
builddate = str(int(time.time())) builddate = str(int(time.time()))
@@ -121,14 +178,23 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None):
pkginfo_lines.append(f"depend = {dep}") pkginfo_lines.append(f"depend = {dep}")
for optdep in optdepends: for optdep in optdepends:
pkginfo_lines.append(f"optdepend = {optdep}") pkginfo_lines.append(f"optdepend = {optdep}")
if all_units:
pkginfo_lines.append(f"install = {name}.install")
pkginfo_lines.append("makepkgopt = strip\n") pkginfo_lines.append("makepkgopt = strip\n")
with open(os.path.join(build_dir, ".PKGINFO"), "w") as f: with open(os.path.join(build_dir, ".PKGINFO"), "w") as f:
f.write("\n".join(pkginfo_lines)) 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) os.makedirs("target/arch", exist_ok=True)
output_file = os.path.abspath(f"target/arch/{name}-{version}-{pkgrel}-{arch}.pkg.tar.zst") output_file = os.path.abspath(f"target/arch/{name}-{version}-{pkgrel}-{arch}.pkg.tar.zst")
subprocess.run(["tar", "--zstd", "-cf", output_file, ".PKGINFO", "usr"], cwd=build_dir, check=True) subprocess.run(["tar", "--zstd", "-cf", output_file, *tar_members], cwd=build_dir, check=True)
print(f"Arch-Paket erfolgreich erstellt: {output_file}") print(f"Arch-Paket erfolgreich erstellt: {output_file}")
+26 -90
View File
@@ -12,7 +12,7 @@ use clap::{Args, Subcommand};
use dialoguer::{Confirm, Input, Password, Select}; use dialoguer::{Confirm, Input, Password, Select};
use smart_mount::config::{ use smart_mount::config::{
self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountContext, MountKind, self, AppConfig, CloudSide, DrivePair, LocalAddress, LocalSide, MountKind,
}; };
use smart_mount::db::credentials::{Credential, CredentialStore, Side}; use smart_mount::db::credentials::{Credential, CredentialStore, Side};
@@ -40,8 +40,6 @@ pub enum DriveAction {
pub struct DriveArgs { pub struct DriveArgs {
#[arg(long)] #[arg(long)]
name: Option<String>, name: Option<String>,
#[arg(long, value_enum)]
context: Option<MountContext>,
#[arg(long)] #[arg(long)]
owner_user: Option<String>, owner_user: Option<String>,
@@ -108,10 +106,9 @@ async fn list(json: bool) -> anyhow::Result<()> {
} }
for pair in &cfg.pairs { for pair in &cfg.pairs {
println!( println!(
"{} \"{}\" [{:?}] lokal={} cloud={} -> {}", "{} \"{}\" lokal={} cloud={} -> {}",
pair.id, pair.id,
pair.name, pair.name,
pair.context,
pair.local.kind.as_str(), pair.local.kind.as_str(),
pair.cloud.kind.as_str(), pair.cloud.kind.as_str(),
pair.mount_point.display() pair.mount_point.display()
@@ -121,6 +118,8 @@ async fn list(json: bool) -> anyhow::Result<()> {
} }
async fn remove(id: &str) -> anyhow::Result<()> { 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 // 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 // Mount-Typen/Adressen betroffen sind - nötig, um die passenden Klartext-Zugangsdaten
// (davfs2 secrets, .cred-Datei) aufzuräumen, siehe smart_mount::mount::cleanup_credentials. // (davfs2 secrets, .cred-Datei) aufzuräumen, siehe smart_mount::mount::cleanup_credentials.
@@ -148,11 +147,11 @@ async fn remove(id: &str) -> anyhow::Result<()> {
} }
async fn add(args: DriveArgs) -> anyhow::Result<()> { async fn add(args: DriveArgs) -> anyhow::Result<()> {
crate::cli::require_root("drive add")?;
let ni = args.non_interactive; let ni = args.non_interactive;
let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required"); let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required");
let context = resolve_context(args.context, None, ni)?; let owner_user = resolve_owner_user(args.owner_user, None, ni)?;
let owner_user = resolve_owner_user(args.owner_user, context, None, ni)?;
let local_kind = resolve_kind(args.local_kind, None, "Local mount type", 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_address = resolve_local_address(args.local_ip, args.local_mac, None, ni)?;
@@ -199,7 +198,6 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> {
id: id.clone(), id: id.clone(),
name, name,
enabled: true, enabled: true,
context,
owner_user, owner_user,
mount_point, mount_point,
local: LocalSide { local: LocalSide {
@@ -233,22 +231,18 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> {
} }
println!("Drive pair '{id}' created."); println!("Drive pair '{id}' created.");
if context == MountContext::User {
println!("Note: for user-context pairs, 'sudo smart-mount setup fstab' must be run once.");
}
Ok(()) Ok(())
} }
async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
crate::cli::require_root("drive edit")?;
let cfg = config::pairs::load()?; let cfg = config::pairs::load()?;
let existing = config::pairs::find_pair(&cfg, id)?; let existing = config::pairs::find_pair(&cfg, id)?;
let ni = args.non_interactive; let ni = args.non_interactive;
let name = resolve_field(args.name, Some(&existing.name), "Drive pair name", ni, true)? let name = resolve_field(args.name, Some(&existing.name), "Drive pair name", ni, true)?
.expect("required"); .expect("required");
let context = resolve_context(args.context, Some(existing.context), ni)?; let owner_user = resolve_owner_user(args.owner_user, existing.owner_user.as_deref(), ni)?;
let owner_user =
resolve_owner_user(args.owner_user, context, existing.owner_user.as_deref(), ni)?;
let local_kind = resolve_kind( let local_kind = resolve_kind(
args.local_kind, args.local_kind,
@@ -320,10 +314,9 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> {
id: id.to_string(), id: id.to_string(),
name, name,
enabled: existing.enabled, enabled: existing.enabled,
context,
owner_user, owner_user,
// Mountpoint (und damit die Backing-Verzeichnisse) bleiben unverändert - sonst würden // Mountpoint (und damit die Backing-Verzeichnisse) bleiben unverändert - sonst würden
// eventuell noch aktive Mounts/fstab-Einträge verwaisen. // eventuell noch aktive Mounts verwaisen.
mount_point: existing.mount_point.clone(), mount_point: existing.mount_point.clone(),
local: LocalSide { local: LocalSide {
kind: local_kind, kind: local_kind,
@@ -398,89 +391,32 @@ fn resolve_field(
Ok(Some(input.interact_text()?)) Ok(Some(input.interact_text()?))
} }
fn resolve_context( /// Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff
flag: Option<MountContext>, /// (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership - der
current: Option<MountContext>, /// Mount selbst läuft immer als root (siehe [`smart_mount::systemd`]).
non_interactive: bool,
) -> anyhow::Result<MountContext> {
if let Some(c) = flag {
return Ok(c);
}
if let Some(c) = current
&& non_interactive
{
return Ok(c);
}
if non_interactive {
anyhow::bail!(
"Field 'context' is missing - specify it via '--context system|user' in non-interactive mode."
);
}
let default_idx = if current == Some(MountContext::System) {
0
} else {
1
};
let idx = Select::new()
.with_prompt("Context")
.items(["System (root)", "User"])
.default(default_idx)
.interact()?;
Ok(if idx == 0 {
MountContext::System
} else {
MountContext::User
})
}
fn resolve_owner_user( fn resolve_owner_user(
flag: Option<String>, flag: Option<String>,
context: MountContext,
current: Option<&str>, current: Option<&str>,
non_interactive: bool, non_interactive: bool,
) -> anyhow::Result<Option<String>> { ) -> anyhow::Result<Option<String>> {
if let Some(v) = flag { if let Some(v) = flag {
return Ok(Some(v)); return Ok(Some(v));
} }
match context { if non_interactive {
MountContext::User => { return Ok(current.map(str::to_string));
// Pflicht: wird auch für 'setup fstab' (Gruppenmitgliedschaft, Verzeichnis-Owner)
// und für die uid=/gid=-Zugriffsrechte benötigt.
if non_interactive {
return current.map(str::to_string).map(Some).ok_or_else(|| {
anyhow::anyhow!("Field 'owner_user' is required for context 'user'.")
});
}
let default_user = current
.map(str::to_string)
.unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()));
Ok(Some(
Input::new()
.with_prompt("Linux username (owner)")
.default(default_user)
.interact_text()?,
))
}
MountContext::System => {
// 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.
if non_interactive {
return Ok(current.map(str::to_string));
}
let want_owner = Confirm::new()
.with_prompt("Should a specific user get full access to this drive (uid/gid, including script execution)?")
.default(current.is_some())
.interact()?;
if !want_owner {
return Ok(None);
}
let mut input = Input::<String>::new().with_prompt("Linux username");
if let Some(c) = current {
input = input.default(c.to_string());
}
Ok(Some(input.interact_text()?))
}
} }
let want_owner = Confirm::new()
.with_prompt("Should a specific user get full access to this drive (uid/gid, including script execution)?")
.default(current.is_some())
.interact()?;
if !want_owner {
return Ok(None);
}
let mut input = Input::<String>::new().with_prompt("Linux username");
if let Some(c) = current {
input = input.default(c.to_string());
}
Ok(Some(input.interact_text()?))
} }
fn resolve_kind( fn resolve_kind(
+15 -9
View File
@@ -4,13 +4,23 @@ pub mod doctor;
pub mod drive; pub mod drive;
pub mod mount_cmd; pub mod mount_cmd;
pub mod service; pub mod service;
pub mod setup;
pub mod status; pub mod status;
pub mod watch; pub mod watch;
use clap::{CommandFactory, Parser, Subcommand}; use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::Shell; 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)] #[derive(Parser)]
#[command( #[command(
name = "smart-mount", name = "smart-mount",
@@ -55,17 +65,14 @@ pub enum Commands {
}, },
/// A single reconcile pass (local/cloud switching) - meant for systemd timers/cron. /// A single reconcile pass (local/cloud switching) - meant for systemd timers/cron.
Watch, Watch,
/// Install/remove systemd units. /// 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 { Service {
#[command(subcommand)] #[command(subcommand)]
action: service::ServiceAction, action: service::ServiceAction,
}, },
/// One-time root setup for unprivileged user mounts. /// Checks prerequisites (binaries, scheduler).
Setup {
#[command(subcommand)]
action: setup::SetupAction,
},
/// Checks prerequisites (binaries, group membership, fstab setup, scheduler).
Doctor { Doctor {
/// Output as JSON instead of text - for scripts. /// Output as JSON instead of text - for scripts.
#[arg(long)] #[arg(long)]
@@ -85,7 +92,6 @@ pub async fn dispatch(cli: Cli) -> anyhow::Result<()> {
Commands::Status { name, json } => status::run(name, json).await, Commands::Status { name, json } => status::run(name, json).await,
Commands::Watch => watch::run().await, Commands::Watch => watch::run().await,
Commands::Service { action } => service::run(action), Commands::Service { action } => service::run(action),
Commands::Setup { action } => setup::run(action),
Commands::Doctor { json } => doctor::run(json).await, Commands::Doctor { json } => doctor::run(json).await,
Commands::Completions { shell } => { Commands::Completions { shell } => {
clap_complete::generate( clap_complete::generate(
+4 -19
View File
@@ -1,6 +1,6 @@
//! `smart-mount mount` / `smart-mount unmount`. //! `smart-mount mount` / `smart-mount unmount`.
use smart_mount::config::{self, DrivePair, MountContext}; use smart_mount::config::{self, DrivePair};
use smart_mount::db::credentials::CredentialStore; use smart_mount::db::credentials::CredentialStore;
use smart_mount::reconcile; use smart_mount::reconcile;
@@ -15,27 +15,11 @@ fn select_pairs(
if !all { if !all {
anyhow::bail!("Please specify '--name <id>' or '--all'."); anyhow::bail!("Please specify '--name <id>' or '--all'.");
} }
Ok(cfg.pairs.clone())
let pairs = if sudo_ctdra::is_run_as_root() {
cfg.pairs
.iter()
.filter(|p| p.context == MountContext::System)
.cloned()
.collect()
} else {
let user = std::env::var("USER").unwrap_or_default();
cfg.pairs
.iter()
.filter(|p| {
p.context == MountContext::User && p.owner_user.as_deref() == Some(user.as_str())
})
.cloned()
.collect()
};
Ok(pairs)
} }
pub async fn run_mount(name: Option<String>, all: bool) -> anyhow::Result<()> { pub async fn run_mount(name: Option<String>, all: bool) -> anyhow::Result<()> {
crate::cli::require_root("mount")?;
let cfg = config::pairs::load()?; let cfg = config::pairs::load()?;
let pairs = select_pairs(&cfg, name.as_deref(), all)?; let pairs = select_pairs(&cfg, name.as_deref(), all)?;
if pairs.is_empty() { if pairs.is_empty() {
@@ -52,6 +36,7 @@ pub async fn run_mount(name: Option<String>, all: bool) -> anyhow::Result<()> {
} }
pub async fn run_unmount(name: Option<String>, all: bool) -> anyhow::Result<()> { pub async fn run_unmount(name: Option<String>, all: bool) -> anyhow::Result<()> {
crate::cli::require_root("unmount")?;
let cfg = config::pairs::load()?; let cfg = config::pairs::load()?;
let pairs = select_pairs(&cfg, name.as_deref(), all)?; let pairs = select_pairs(&cfg, name.as_deref(), all)?;
if pairs.is_empty() { if pairs.is_empty() {
+43 -141
View File
@@ -1,152 +1,54 @@
//! `smart-mount service install|uninstall --system|--user`. //! `smart-mount service crontab [--remove]`.
use clap::{Args, Subcommand}; use clap::Subcommand;
use smart_mount::config; use smart_mount::config;
use smart_mount::fstab; use smart_mount::systemd;
use smart_mount::systemd::{self, Scope};
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum ServiceAction { pub enum ServiceAction {
/// Sets up periodic execution: systemd if available, otherwise falls back to cron /// Sets up (or, with '--remove', tears down) periodic execution via
/// automatically (see `crontab`). /// '/etc/cron.d/smart-mount' - a manual fallback for systems that don't use the packaged
Install(ScopeArgs), /// systemd service (see 'packaging/systemd/' in the source tree). Requires root, same as
/// Removes everything that `install`/`crontab`/`setup fstab` have set up - systemd /// 'mount'/'watch' - mounting always runs as root.
/// units, cron entry, and (only for `--system`) the managed `/etc/fstab` block. Crontab {
/// Missing parts are skipped, not treated as an error. /// Removes a previously installed cron entry instead of installing one.
Uninstall(ScopeArgs), #[arg(long)]
/// Sets up periodic execution via cron (alternative to `install` for systems without remove: bool,
/// systemd) - system or user context is chosen automatically based on the current },
/// privileges (root -> `/etc/cron.d/smart-mount`, otherwise personal crontab). If no
/// cron mechanism is present, the lines for manual entry are printed instead.
Crontab,
}
#[derive(Args)]
pub struct ScopeArgs {
#[arg(long, conflicts_with = "user")]
system: bool,
#[arg(long, conflicts_with = "system")]
user: bool,
}
impl ScopeArgs {
fn scope(&self) -> anyhow::Result<Scope> {
match (self.system, self.user) {
(true, false) => Ok(Scope::System),
(false, true) => Ok(Scope::User),
_ => anyhow::bail!("Please specify exactly one of '--system' or '--user'."),
}
}
} }
pub fn run(action: ServiceAction) -> anyhow::Result<()> { pub fn run(action: ServiceAction) -> anyhow::Result<()> {
crate::cli::require_root("service crontab")?;
match action { match action {
ServiceAction::Install(args) => { ServiceAction::Crontab { remove: false } => install(),
let scope = args.scope()?; ServiceAction::Crontab { remove: true } => uninstall(),
if scope == Scope::System && !sudo_ctdra::is_run_as_root() {
anyhow::bail!(
"'service install --system' requires root privileges (re-run with sudo)."
);
}
let cfg = config::pairs::load()?;
let interval = cfg.settings.watch_interval_secs;
if systemd::is_available() {
systemd::install(scope, interval)?;
println!("systemd units installed and enabled ({scope:?}).");
} else {
println!("systemd not found - setting up cron instead.");
match systemd::install_cron(scope, interval)? {
systemd::CronInstallOutcome::SystemFile(path) => {
println!("Cron entry written: {}", path.display());
}
systemd::CronInstallOutcome::UserCrontab => {
println!("Personal crontab updated (see 'crontab -l').");
}
systemd::CronInstallOutcome::Unavailable => {
println!(
"Neither systemd nor cron found - here are the lines for manual entry:"
);
print!("{}", systemd::crontab_equivalent(interval));
}
}
}
Ok(())
}
ServiceAction::Uninstall(args) => {
let scope = args.scope()?;
if scope == Scope::System && !sudo_ctdra::is_run_as_root() {
anyhow::bail!(
"'service uninstall --system' requires root privileges (re-run with sudo)."
);
}
let mut removed = Vec::new();
if systemd::is_available() {
match systemd::uninstall(scope)? {
systemd::SystemdUninstallOutcome::Removed => removed.push("systemd units"),
systemd::SystemdUninstallOutcome::NotPresent => {}
}
}
match systemd::uninstall_cron(scope)? {
systemd::CronUninstallOutcome::Removed => removed.push("cron entry"),
systemd::CronUninstallOutcome::NotPresent => {}
}
// fstab-Einträge sind unabhängig vom --system/--user-Scope des Aufrufers immer
// root-weit (setup() betrifft alle User-Kontext-Paare) - nur bei --system mit
// aufräumen, damit ein `--user`-Uninstall nicht versehentlich Root-Konfiguration
// anfasst, die ein anderer Nutzer noch braucht.
if scope == Scope::System {
match fstab::teardown()? {
fstab::FstabTeardownOutcome::Removed => removed.push("fstab entries"),
fstab::FstabTeardownOutcome::NotPresent => {}
}
}
if removed.is_empty() {
println!("Nothing to remove - nothing was installed ({scope:?}).");
} else {
println!("Removed ({scope:?}): {}", removed.join(", "));
}
Ok(())
}
ServiceAction::Crontab => {
let cfg = config::pairs::load()?;
let interval = cfg.settings.watch_interval_secs;
// Scope folgt automatisch den aktuellen Rechten, wie bei `mount --all` -
// root pflegt den systemweiten Cron-Eintrag, ein normaler Nutzer seine eigene
// Crontab. Anders als bei `install`/`uninstall` gibt es hier bewusst keine
// expliziten `--system`/`--user`-Flags, weil die Wahl ohnehin durch die Rechte
// vorgegeben ist (root kann nicht "versehentlich" die falsche Crontab treffen).
let scope = if sudo_ctdra::is_run_as_root() {
Scope::System
} else {
Scope::User
};
match systemd::install_cron(scope, interval)? {
systemd::CronInstallOutcome::SystemFile(path) => {
println!("Cron entry written: {}", path.display());
}
systemd::CronInstallOutcome::UserCrontab => {
println!("Personal crontab updated (see 'crontab -l').");
}
systemd::CronInstallOutcome::Unavailable => {
println!(
"No cron mechanism found ({}) - here are the lines for manual entry:",
if scope == Scope::System {
"/etc/cron.d is missing"
} else {
"'crontab' not in PATH"
}
);
print!("{}", systemd::crontab_equivalent(interval));
}
}
Ok(())
}
} }
} }
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(())
}
-21
View File
@@ -1,21 +0,0 @@
//! `smart-mount setup fstab`.
use clap::Subcommand;
use smart_mount::fstab;
#[derive(Subcommand)]
pub enum SetupAction {
/// One-time root setup: `/etc/fstab` entries + group membership for
/// unprivileged user mounts.
Fstab,
}
pub fn run(action: SetupAction) -> anyhow::Result<()> {
match action {
SetupAction::Fstab => {
fstab::setup()?;
println!("fstab setup complete.");
Ok(())
}
}
}
+1
View File
@@ -7,6 +7,7 @@ use smart_mount::reconcile::{self, Action};
use crate::cli::mount_cmd::print_outcome; use crate::cli::mount_cmd::print_outcome;
pub async fn run() -> anyhow::Result<()> { pub async fn run() -> anyhow::Result<()> {
crate::cli::require_root("watch")?;
let cfg = config::pairs::load()?; let cfg = config::pairs::load()?;
let creds = CredentialStore::open().await?; let creds = CredentialStore::open().await?;
let outcomes = reconcile::watch_once(&cfg, &creds).await; let outcomes = reconcile::watch_once(&cfg, &creds).await;
+12 -4
View File
@@ -4,12 +4,20 @@ pub mod pairs;
pub mod schema; pub mod schema;
pub use schema::{ pub use schema::{
AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountContext, AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountKind,
MountKind,
}; };
/// Initialisiert den Konfigurationsdateinamen bei `config-ctdra`. Muss vor dem ersten /// Initialisiert `config-ctdra`: Dateiname und - unabhängig von den Rechten des aufrufenden
/// `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter Zustand). /// 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() { pub fn init() {
config_ctdra::set_config_name("config"); 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));
} }
+20 -60
View File
@@ -11,39 +11,13 @@ use serde::{Deserialize, Serialize};
/// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene /// `/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 /// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei
/// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie /// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie
/// persistieren müssen. Root-/System-Kontext-Paare landen flach unter `/run/media/smart-mount` /// persistieren müssen. Ein einzelner, flacher Namensraum reicht: smart-mount läuft
/// (ein systemweiter Dienst, keinem einzelnen Nutzer zugeordnet); Nutzer-Kontext-Paare unter /// ausschließlich als root/System-Dienst (siehe [`crate::systemd`]) und mountet dort auch
/// `/run/media/<Nutzer>/smart-mount`, damit mehrere lokale Nutzer mit eigenen Paaren sich /// Paare mit gesetztem `owner_user` - die Trennung nach Linux-Nutzer passiert über die
/// nicht denselben Namensraum teilen. /// `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über
/// unterschiedliche Mountpoint-Namensräume.
fn default_mount_base_dir() -> PathBuf { fn default_mount_base_dir() -> PathBuf {
if sudo_ctdra::is_run_as_root() { PathBuf::from("/run/media/smart-mount")
PathBuf::from("/run/media/smart-mount")
} else {
// Absichtlich KEIN Fallback auf einen festen Platzhalter wie "user": unter `systemd
// --user` (wo $USER nicht immer gesetzt ist) würden dann zwei verschiedene reale
// Nutzer denselben Mountpoint-Namensraum `/run/media/user/smart-mount` teilen - genau
// die Kollision, die die Aufteilung nach Nutzername eigentlich verhindern soll. `id
// -un` liest den Nutzernamen stattdessen direkt vom Kernel.
let run_id = |flag: &str| {
std::process::Command::new("id")
.arg(flag)
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
};
let user = std::env::var("USER")
.ok()
.filter(|u| !u.is_empty())
.or_else(|| run_id("-un"))
// Letzter Ausweg: die numerische UID ist immer verfügbar und auf jedem System
// eindeutig, anders als ein fest codierter Platzhalter-String wie "user", der bei
// mehreren betroffenen Nutzern denselben Namensraum kollidieren ließe.
.or_else(|| run_id("-u").map(|uid| format!("uid-{uid}")))
.unwrap_or_else(|| "user".to_string());
PathBuf::from("/run/media").join(user).join("smart-mount")
}
} }
fn default_log_level() -> String { fn default_log_level() -> String {
@@ -58,9 +32,8 @@ fn default_mac2ip_binary() -> String {
"mac2ip".to_string() "mac2ip".to_string()
} }
/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` unter /// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` immer unter
/// `~/.config/smart-mount/config.toml` (Nutzerkontext) bzw. `/etc/smart-mount/config.toml` /// `/etc/smart-mount/config.toml` (siehe [`crate::config::init`]).
/// (Root-Kontext).
#[derive(Serialize, Deserialize, Clone, Debug, Default)] #[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct AppConfig { pub struct AppConfig {
#[serde(default)] #[serde(default)]
@@ -76,8 +49,10 @@ pub struct GlobalSettings {
pub mount_base_dir: PathBuf, pub mount_base_dir: PathBuf,
#[serde(default = "default_log_level")] #[serde(default = "default_log_level")]
pub log_level: String, pub log_level: String,
/// Periode, mit der `smart-mount watch` über den generierten systemd-Timer bzw. die /// Periode, mit der `smart-mount watch` über den Cron-Fallback (`smart-mount service
/// dokumentierte Crontab-Zeile ausgeführt werden soll. /// 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")] #[serde(default = "default_watch_interval_secs")]
pub watch_interval_secs: u64, pub watch_interval_secs: u64,
/// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad). /// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad).
@@ -101,14 +76,14 @@ impl Default for GlobalSettings {
/// eingebunden - siehe [`crate::reconcile`]. /// eingebunden - siehe [`crate::reconcile`].
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DrivePair { pub struct DrivePair {
/// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten, Mount-Unterverzeichnis, /// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten sowie das Mount-Unterverzeichnis.
/// systemd-Unit-Namen und fstab-Einträge.
pub id: String, pub id: String,
pub name: String, pub name: String,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enabled: bool, pub enabled: bool,
pub context: MountContext, /// Optional: der Linux-Benutzername, der vollen Zugriff (uid/gid) auf dieses Paar
/// Pflicht bei `context == User`: der Linux-Benutzername, dem dieses Paar gehört. /// bekommen soll (siehe [`crate::mount::target::build_target`]). Ohne `owner_user` gehört
/// der Mount root.
#[serde(default)] #[serde(default)]
pub owner_user: Option<String>, pub owner_user: Option<String>,
pub mount_point: PathBuf, pub mount_point: PathBuf,
@@ -120,16 +95,6 @@ fn default_true() -> bool {
true true
} }
/// Ob ein Laufwerkspaar systemweit (root, `/etc/fstab`+systemd-System-Service) oder als
/// einzelner Nutzer (`systemd --user`, unprivilegiert über `setup fstab`) eingebunden wird.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
#[value(rename_all = "lowercase")]
pub enum MountContext {
System,
User,
}
/// Die lokale (LAN-)Seite eines Laufwerkspaars. /// Die lokale (LAN-)Seite eines Laufwerkspaars.
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LocalSide { pub struct LocalSide {
@@ -198,7 +163,6 @@ mod tests {
id: "pair-1".into(), id: "pair-1".into(),
name: "NAS".into(), name: "NAS".into(),
enabled: true, enabled: true,
context: MountContext::User,
owner_user: Some("dragon".into()), owner_user: Some("dragon".into()),
mount_point: PathBuf::from("/home/dragon/smart-mount/pair-1"), mount_point: PathBuf::from("/home/dragon/smart-mount/pair-1"),
local: LocalSide { local: LocalSide {
@@ -223,7 +187,6 @@ mod tests {
assert_eq!(round_tripped.pairs.len(), 1); assert_eq!(round_tripped.pairs.len(), 1);
assert_eq!(round_tripped.pairs[0].id, "pair-1"); assert_eq!(round_tripped.pairs[0].id, "pair-1");
assert_eq!(round_tripped.pairs[0].context, MountContext::User);
match &round_tripped.pairs[0].local.address { match &round_tripped.pairs[0].local.address {
LocalAddress::Mac(mac) => assert_eq!(mac, "aa:bb:cc:dd:ee:ff"), LocalAddress::Mac(mac) => assert_eq!(mac, "aa:bb:cc:dd:ee:ff"),
LocalAddress::Ip(_) => panic!("expected Mac variant"), LocalAddress::Ip(_) => panic!("expected Mac variant"),
@@ -232,12 +195,9 @@ mod tests {
#[test] #[test]
fn default_mount_base_dir_uses_run_media() { fn default_mount_base_dir_uses_run_media() {
// Tests laufen nie als root, daher greift hier immer der Nutzerkontext-Zweig. assert_eq!(
let dir = default_mount_base_dir(); default_mount_base_dir(),
assert!(dir.starts_with("/run/media")); PathBuf::from("/run/media/smart-mount")
assert!(dir.ends_with("smart-mount")); );
if let Ok(user) = std::env::var("USER") {
assert!(dir.to_string_lossy().contains(&user));
}
} }
} }
+5 -125
View File
@@ -1,11 +1,8 @@
//! Master-Schlüssel-Auflösung für die Zugangsdaten-Verschlüsselung. //! Master-Schlüssel-Auflösung für die Zugangsdaten-Verschlüsselung.
//! //!
//! Reihenfolge (wie mit dem Nutzer abgestimmt): //! Immer die Schlüsseldatei: SmartMount läuft ausschließlich als root/System-Dienst (siehe
//! - Root-/System-Kontext: **immer** die Schlüsseldatei (kein Nutzer-Keyring im //! [`crate::systemd`]), für den es kein Nutzer-Keyring (GNOME Keyring/KWallet über
//! Systemdienst-Kontext verfügbar). //! secret-service) gibt.
//! - Nutzerkontext: zuerst das OS-Keyring (GNOME Keyring/KWallet über secret-service)
//! versuchen, bei Nichtverfügbarkeit (z. B. Headless-Server, kein D-Bus-Secret-Service)
//! transparent auf die Schlüsseldatei zurückfallen.
use std::fs::{self, File, OpenOptions}; use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write}; use std::io::{Read, Write};
@@ -13,64 +10,13 @@ use std::path::{Path, PathBuf};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
const KEYRING_SERVICE: &str = "smart-mount";
const KEYRING_USERNAME: &str = "master-key";
const KEY_FILE_NAME: &str = "master.key"; const KEY_FILE_NAME: &str = "master.key";
const KEY_BACKEND_MARKER_NAME: &str = "master.key.backend";
const KEY_LEN: usize = 32; const KEY_LEN: usize = 32;
/// Ermittelt (und erzeugt bei Bedarf) den 256-Bit-Master-Schlüssel für die /// Ermittelt (und erzeugt bei Bedarf) den 256-Bit-Master-Schlüssel für die
/// Zugangsdaten-Verschlüsselung, siehe Modul-Dokumentation für die Fallback-Reihenfolge. /// Zugangsdaten-Verschlüsselung.
///
/// Welcher Backend (Keyring oder Schlüsseldatei) für einen Nutzer verwendet wird, wird beim
/// ersten Aufruf in einer Marker-Datei festgehalten und danach immer wieder verwendet. Ohne
/// diese Festlegung würde eine vorübergehend nicht erreichbare Keyring (z. B. `systemd --user`
/// ohne D-Bus-Secret-Service) sonst bei jedem Aufruf transparent einen *neuen* Datei-Schlüssel
/// erzeugen und damit zuvor unter dem Keyring-Schlüssel verschlüsselte Zugangsdaten unwiderruflich
/// unlesbar machen.
pub fn resolve_master_key() -> Result<[u8; 32]> { pub fn resolve_master_key() -> Result<[u8; 32]> {
if sudo_ctdra::is_run_as_root() { file_key::load_or_create(&key_file_path())
return file_key::load_or_create(&key_file_path());
}
let marker_path = key_backend_marker_path();
match fs::read_to_string(&marker_path) {
Ok(backend) => match backend.trim() {
"keyring" => keyring_key::load_or_create()
.map_err(|reason| Error::Crypto(format!("OS keyring not available ({reason})"))),
_ => file_key::load_or_create(&key_file_path()),
},
Err(_) => match keyring_key::load_or_create() {
Ok(key) => {
write_key_backend_marker(&marker_path, "keyring");
Ok(key)
}
Err(reason) => {
logger_ctdra::warn(
"crypto",
&format!("OS keyring not available ({reason}), using key file"),
);
let key = file_key::load_or_create(&key_file_path())?;
write_key_backend_marker(&marker_path, "file");
Ok(key)
}
},
}
}
fn write_key_backend_marker(marker_path: &Path, backend: &str) {
if let Some(dir) = marker_path.parent() {
let _ = fs::create_dir_all(dir);
}
if let Err(e) = fs::write(marker_path, backend) {
logger_ctdra::warn(
"crypto",
&format!(
"could not persist key backend marker '{}': {e}",
marker_path.display()
),
);
}
} }
fn key_file_path() -> PathBuf { fn key_file_path() -> PathBuf {
@@ -81,14 +27,6 @@ fn key_file_path() -> PathBuf {
.unwrap_or_else(|| PathBuf::from(KEY_FILE_NAME)) .unwrap_or_else(|| PathBuf::from(KEY_FILE_NAME))
} }
fn key_backend_marker_path() -> PathBuf {
let config_path = config_ctdra::get_config_path();
config_path
.parent()
.map(|dir| dir.join(KEY_BACKEND_MARKER_NAME))
.unwrap_or_else(|| PathBuf::from(KEY_BACKEND_MARKER_NAME))
}
mod file_key { mod file_key {
use super::*; use super::*;
@@ -150,61 +88,3 @@ mod file_key {
.map_err(|e| Error::Crypto(format!("Random number generator failed: {e}"))) .map_err(|e| Error::Crypto(format!("Random number generator failed: {e}")))
} }
} }
mod keyring_key {
use super::*;
pub fn load_or_create() -> std::result::Result<[u8; 32], String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USERNAME)
.map_err(|e| format!("Could not create keyring entry: {e}"))?;
match entry.get_password() {
Ok(hex_key) => decode(&hex_key),
Err(keyring::Error::NoEntry) => {
let key = generate()?;
if let Err(e) = entry.set_password(&encode(&key)) {
// A concurrent first run may have already created the entry; use its key
// instead of failing outright.
return match entry.get_password() {
Ok(hex_key) => decode(&hex_key),
Err(_) => Err(format!("Could not store key in keyring: {e}")),
};
}
// Re-read the entry: a concurrent writer may have overwritten ours after our
// own `set_password` succeeded. Using whichever key ultimately "won" ensures
// both processes agree on the same key instead of one silently using a key
// that was never actually persisted.
match entry.get_password() {
Ok(hex_key) => decode(&hex_key),
Err(_) => Ok(key),
}
}
Err(e) => Err(format!("Keyring access failed: {e}")),
}
}
fn generate() -> std::result::Result<[u8; 32], String> {
let mut key = [0u8; 32];
::getrandom::fill(&mut key).map_err(|e| format!("Random number generator failed: {e}"))?;
Ok(key)
}
fn encode(key: &[u8; 32]) -> String {
key.iter().map(|b| format!("{b:02x}")).collect()
}
fn decode(hex_key: &str) -> std::result::Result<[u8; 32], String> {
if hex_key.len() != 64 {
return Err(format!(
"unexpected key length in keyring ({} instead of 64 hex characters)",
hex_key.len()
));
}
let mut key = [0u8; 32];
for (i, chunk) in hex_key.as_bytes().chunks(2).enumerate() {
let byte_str = std::str::from_utf8(chunk).map_err(|e| e.to_string())?;
key[i] = u8::from_str_radix(byte_str, 16).map_err(|e| e.to_string())?;
}
Ok(key)
}
}
+10 -124
View File
@@ -1,11 +1,10 @@
//! Diagnose-Checks für `smart-mount doctor` - prüft die im Laufe der Entwicklung //! Diagnose-Checks für `smart-mount doctor` - prüft die im Laufe der Entwicklung
//! angesammelten Voraussetzungen (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) //! angesammelten Voraussetzungen (Binaries, Scheduler) gebündelt an einer Stelle, statt sie
//! gebündelt an einer Stelle, statt sie einzeln erst beim Mount-Fehlschlag zu entdecken. //! einzeln erst beim Mount-Fehlschlag zu entdecken.
use std::collections::HashSet; use std::collections::HashSet;
use std::process::Command;
use crate::config::{AppConfig, DrivePair, LocalAddress, MountContext, MountKind}; use crate::config::{AppConfig, LocalAddress, MountKind};
use crate::mount; use crate::mount;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -68,23 +67,6 @@ pub fn run_checks(cfg: &AppConfig) -> Vec<CheckResult> {
results.push(check_binary("nmap", "nmap", "install package 'nmap'")); results.push(check_binary("nmap", "nmap", "install package 'nmap'"));
} }
if cfg.pairs.iter().any(|p| p.context == MountContext::User) {
results.push(check_fstab_setup(cfg));
if used_kinds.contains(&MountKind::WebDav) {
results.push(check_davfs2_group_membership(cfg));
}
}
for pair in &cfg.pairs {
if pair.context == MountContext::User && pair.owner_user.is_none() {
results.push(fail(
format!("Pair '{}': owner_user", pair.name),
"context 'user', but no owner_user set - 'setup fstab' will reject this."
.to_string(),
));
}
}
results results
} }
@@ -141,12 +123,12 @@ fn check_scheduler() -> CheckResult {
if systemd { if systemd {
ok( ok(
"Scheduler", "Scheduler",
"systemd found - 'smart-mount service install' uses systemd timers", "systemd found - the packaged .deb/.rpm/.pkg.tar.zst installs/enables the smart-mount systemd service automatically",
) )
} else if cron_d || crontab { } else if cron_d || crontab {
warn( warn(
"Scheduler", "Scheduler",
"no systemd, but cron found - 'smart-mount service install' automatically falls back to cron", "no systemd, but cron found - run 'smart-mount service crontab' to set up the periodic 'watch' call",
) )
} else { } else {
fail( fail(
@@ -178,89 +160,17 @@ fn check_mount_base_dir(dir: &std::path::Path) -> CheckResult {
} }
} }
fn check_fstab_setup(cfg: &AppConfig) -> CheckResult {
let existing = std::fs::read_to_string("/etc/fstab").unwrap_or_default();
if existing.contains("# BEGIN smart-mount managed block") {
ok("setup fstab", "managed block found in /etc/fstab")
} else {
let count = cfg
.pairs
.iter()
.filter(|p| p.context == MountContext::User)
.count();
fail(
"setup fstab",
format!(
"no managed block found in /etc/fstab, but {count} user-context pair(s) configured - run 'sudo smart-mount setup fstab'"
),
)
}
}
fn check_davfs2_group_membership(cfg: &AppConfig) -> CheckResult {
let owners: HashSet<&str> = cfg
.pairs
.iter()
.filter(|p| {
p.context == MountContext::User
&& (p.local.kind == MountKind::WebDav || p.cloud.kind == MountKind::WebDav)
})
.filter_map(|p: &DrivePair| p.owner_user.as_deref())
.collect();
if owners.is_empty() {
return ok(
"davfs2 group membership",
"no WebDAV user-context pairs with owner_user - nothing to check",
);
}
let mut missing = Vec::new();
for owner in &owners {
let output = Command::new("id").args(["-nG", owner]).output();
let is_member = output
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.split_whitespace()
.any(|g| g == "davfs2")
})
.unwrap_or(false);
if !is_member {
missing.push(*owner);
}
}
if missing.is_empty() {
ok(
"davfs2 group membership",
format!(
"all affected users ({}) are members of the 'davfs2' group",
owners.len()
),
)
} else {
warn(
"davfs2 group membership",
format!(
"users without 'davfs2' group: {} - run 'sudo smart-mount setup fstab' (log out and back in afterwards if needed)",
missing.join(", ")
),
)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{CloudSide, GlobalSettings, LocalSide}; use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
fn sample_pair(context: MountContext, owner_user: Option<&str>, mac: bool) -> DrivePair { fn sample_pair(owner_user: Option<&str>, mac: bool) -> DrivePair {
DrivePair { DrivePair {
id: "pair-1".into(), id: "pair-1".into(),
name: "Test".into(), name: "Test".into(),
enabled: true, enabled: true,
context,
owner_user: owner_user.map(str::to_string), owner_user: owner_user.map(str::to_string),
mount_point: "/media/smart-mount/pair-1".into(), mount_point: "/media/smart-mount/pair-1".into(),
local: LocalSide { local: LocalSide {
@@ -288,7 +198,7 @@ mod tests {
fn used_mount_kinds_collects_both_sides_across_pairs() { fn used_mount_kinds_collects_both_sides_across_pairs() {
let cfg = AppConfig { let cfg = AppConfig {
settings: GlobalSettings::default(), settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::System, None, false)], pairs: vec![sample_pair(None, false)],
}; };
let kinds = used_mount_kinds(&cfg); let kinds = used_mount_kinds(&cfg);
assert!(kinds.contains(&MountKind::Nfs)); assert!(kinds.contains(&MountKind::Nfs));
@@ -300,40 +210,16 @@ mod tests {
fn uses_mac_addressing_detects_mac_pairs() { fn uses_mac_addressing_detects_mac_pairs() {
let with_mac = AppConfig { let with_mac = AppConfig {
settings: GlobalSettings::default(), settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::System, None, true)], pairs: vec![sample_pair(None, true)],
}; };
let without_mac = AppConfig { let without_mac = AppConfig {
settings: GlobalSettings::default(), settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::System, None, false)], pairs: vec![sample_pair(None, false)],
}; };
assert!(uses_mac_addressing(&with_mac)); assert!(uses_mac_addressing(&with_mac));
assert!(!uses_mac_addressing(&without_mac)); assert!(!uses_mac_addressing(&without_mac));
} }
#[test]
fn flags_user_context_pair_without_owner_user() {
let cfg = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::User, None, false)],
};
let results = run_checks(&cfg);
assert!(
results
.iter()
.any(|r| r.status == CheckStatus::Fail && r.label.contains("owner_user"))
);
}
#[test]
fn does_not_flag_owner_user_when_present() {
let cfg = AppConfig {
settings: GlobalSettings::default(),
pairs: vec![sample_pair(MountContext::User, Some("dragon"), false)],
};
let results = run_checks(&cfg);
assert!(!results.iter().any(|r| r.label.contains("owner_user")));
}
#[test] #[test]
fn empty_config_still_runs_global_checks_without_panicking() { fn empty_config_still_runs_global_checks_without_panicking() {
let cfg = AppConfig::default(); let cfg = AppConfig::default();
-3
View File
@@ -39,9 +39,6 @@ pub enum Error {
#[error("mac2ip resolution failed for MAC {mac}: {reason}")] #[error("mac2ip resolution failed for MAC {mac}: {reason}")]
Mac2Ip { mac: String, reason: String }, Mac2Ip { mac: String, reason: String },
#[error("No root context: {0}")]
RequiresRoot(&'static str),
#[error("{0}")] #[error("{0}")]
Other(String), Other(String),
} }
-683
View File
@@ -1,683 +0,0 @@
//! Einmaliges root-Setup, das unprivilegierten `User`-Kontext-Paaren erlaubt, sich selbst
//! (unprivilegiert) zu mounten/unmounten.
//!
//! Kernidee: pro Paar werden **zwei** `/etc/fstab`-Zeilen geschrieben - je eine pro Seite,
//! auf das jeweils eindeutige Backing-Verzeichnis dieser Seite (siehe
//! [`crate::mount::target::backing_dir`]), nicht auf einen gemeinsamen Mountpoint. Damit
//! entspricht jede Zeile exakt dem einzigen in `man 8 mount` ("Non-superuser mounts")
//! dokumentierten Fall - genau eine fstab-Zeile pro Ziel - statt sich auf unspezifiziertes
//! Verhalten bei zwei Zeilen mit demselben Ziel zu verlassen. Der sichtbare `pair.mount_point`
//! selbst erscheint dadurch gar nicht in `/etc/fstab` - er ist ein Symlink, den smart-mount
//! zur Laufzeit zwischen den beiden Backing-Verzeichnissen umschaltet (siehe
//! [`crate::reconcile`]).
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::config::{AppConfig, DrivePair, GlobalSettings, MountContext, MountKind};
use crate::db::credentials::Side;
use crate::error::{Error, Result};
use crate::mount::smb;
use crate::mount::target::{self, backing_dir};
const BEGIN_MARKER: &str = "# BEGIN smart-mount managed block";
const END_MARKER: &str = "# END smart-mount managed block";
const FSTAB_PATH: &str = "/etc/fstab";
/// Führt das einmalige root-Setup für alle `User`-Kontext-Paare aus: fstab-Block
/// regenerieren, Gruppenmitgliedschaft sicherstellen, Mountpoints anlegen.
///
/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root - das ist der
/// einzige Befehl in smart-mount, der das tut (alle anderen root-Aktionen verlangen
/// explizit, bereits als root aufgerufen zu werden).
pub fn setup() -> Result<()> {
if !sudo_ctdra::is_run_as_root() {
let err = sudo_ctdra::run_as_root();
return Err(Error::Other(format!(
"Restart with root privileges failed: {err}"
)));
}
let sudo_user = std::env::var("SUDO_USER").ok().filter(|s| !s.is_empty());
if let Some(sudo_user) = &sudo_user
&& config_ctdra::get_custom_path().is_none()
{
let user_config = user_config_path(sudo_user);
config_ctdra::set_custom_path(&user_config);
}
let cfg = crate::config::pairs::load()?;
let user_pairs: Vec<&DrivePair> = cfg
.pairs
.iter()
.filter(|p| p.context == MountContext::User)
.collect();
if user_pairs.is_empty() {
logger_ctdra::info("fstab", "No user-context pairs configured - nothing to do.");
return Ok(());
}
validate_user_pairs_have_owner(&user_pairs)?;
for pair in &user_pairs {
ensure_backing_dirs(pair)?;
ensure_group_membership(pair)?;
}
// Wessen zuvor installierte, jetzt aber nicht mehr konfigurierte Zeilen beim
// Zusammenführen (siehe `merge_managed_block`) entfernt werden dürfen: bei einem
// `sudo`-Aufruf im Namen eines bestimmten Nutzers ausschließlich dessen eigene Zeilen,
// sonst die Menge der in der (dann root-eigenen) Konfiguration genannten `owner_user`.
// Zeilen ANDERER Nutzer bleiben immer unangetastet - andernfalls würde ein zweiter Nutzer,
// der `setup fstab` für sein eigenes Konto ausführt, die vom ersten Nutzer installierten
// Zeilen löschen, weil sich beide denselben verwalteten Block in `/etc/fstab` teilen.
let owner_scope: Vec<String> = match &sudo_user {
Some(u) => vec![u.clone()],
None => user_pairs
.iter()
.filter_map(|p| p.owner_user.clone())
.collect::<HashSet<_>>()
.into_iter()
.collect(),
};
write_managed_block(&user_pairs, &cfg.settings, &owner_scope)?;
logger_ctdra::info(
"fstab",
"Done. Affected users may need to log out and back in for new group memberships to \
take effect. For MAC-based local drives in user context: set up passwordless sudo \
access to 'nmap' if resolution does not already succeed via the ARP neighbor table \
(see README).",
);
Ok(())
}
/// Ergebnis von [`teardown`].
pub enum FstabTeardownOutcome {
/// Der verwaltete Block wurde gefunden und entfernt.
Removed,
/// Kein von smart-mount verwalteter Block vorhanden - nichts zu tun.
NotPresent,
}
/// Gegenstück zu [`setup`]: entfernt den von smart-mount verwalteten Block wieder aus
/// `/etc/fstab` (Backup wie bei `setup` nach `/etc/fstab.smart-mount.bak`). Rührt bewusst
/// **keine** Backing-Verzeichnisse, gemounteten Daten oder Gruppenmitgliedschaften an - nur
/// die fstab-Zeilen selbst, da das Löschen von Verzeichnissen/Cache-Daten oder das Entfernen
/// aus einer Gruppe ungewollte Nebenwirkungen haben könnte (die Gruppe könnte z. B. auch
/// unabhängig von smart-mount genutzt werden).
///
/// Eskaliert selbst via `sudo_ctdra::run_as_root()`, falls nicht bereits root (wie `setup`).
pub fn teardown() -> Result<FstabTeardownOutcome> {
if !sudo_ctdra::is_run_as_root() {
let err = sudo_ctdra::run_as_root();
return Err(Error::Other(format!(
"Restart with root privileges failed: {err}"
)));
}
let fstab_path = PathBuf::from(FSTAB_PATH);
let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default();
if !existing.contains(BEGIN_MARKER) {
return Ok(FstabTeardownOutcome::NotPresent);
}
backup(&fstab_path, &existing)?;
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
write_atomic(&fstab_path, &without_block)?;
Ok(FstabTeardownOutcome::Removed)
}
/// Ohne `owner_user` würde die fstab-Zeile ohne `uid=`/`gid=` geschrieben - bei davfs2 heißt
/// das laut `man mount.davfs` ("uid=user"/"gid=group"): JEDES Mitglied der Gruppe 'davfs2'
/// dürfte dieses Paar mounten, nicht nur der vorgesehene Besitzer. Lieber hart fehlschlagen,
/// bevor eine unsichere Zeile geschrieben wird, als das still zuzulassen.
fn validate_user_pairs_have_owner(pairs: &[&DrivePair]) -> Result<()> {
for pair in pairs {
if pair.owner_user.is_none() {
return Err(Error::Other(format!(
"Drive pair '{}' has context 'user', but no owner_user set. \
Without owner_user, mount access cannot be restricted to a specific \
user - please set owner_user in the configuration \
(e.g. via 'smart-mount drive add').",
pair.id
)));
}
}
Ok(())
}
/// Legt beide Backing-Verzeichnisse an (nicht `pair.mount_point` selbst - das bleibt ein
/// Symlink, siehe Moduldoku) und macht `owner_user` zum Besitzer beider.
fn ensure_backing_dirs(pair: &DrivePair) -> Result<()> {
for side in [Side::Local, Side::Cloud] {
create_dir_all_owned(&backing_dir(pair, side), pair.owner_user.as_deref())?;
}
// Der Elternordner des sichtbaren Mountpoints (z. B. `/run/media/<Nutzer>/smart-mount`)
// muss dem Nutzer ebenfalls gehören - dort legt `activate_symlink` bei JEDEM `mount`/
// `watch`-Lauf den Symlink an/ersetzt ihn, und das läuft (anders als dieses einmalige
// Setup) unprivilegiert als der Nutzer selbst. `/run/media` ist standardmäßig `root:root
// 0755` - ohne diesen Schritt könnte der Nutzer dort nicht einmal ein eigenes
// Unterverzeichnis anlegen.
if let Some(parent) = pair.mount_point.parent() {
create_dir_all_owned(parent, pair.owner_user.as_deref())?;
}
Ok(())
}
/// Wie `std::fs::create_dir_all`, macht aber zusätzlich `owner` zum Besitzer aller dabei
/// **neu angelegten** Verzeichnisse - nicht bereits vorhandener Elternverzeichnisse (z. B.
/// `/run/media` selbst, das root-eigen bleiben muss). Läuft von `dir` aus rückwärts nach
/// oben, bis der erste bereits existierende Vorfahre gefunden ist.
fn create_dir_all_owned(dir: &std::path::Path, owner: Option<&str>) -> Result<()> {
let Some(owner) = owner else {
return std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e));
};
let mut newly_created = Vec::new();
let mut current = dir;
while !current.exists() {
newly_created.push(current.to_path_buf());
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
std::fs::create_dir_all(dir).map_err(|e| Error::io(dir, e))?;
// Von oben nach unten chownen (Eltern vor Kindern) - rein kosmetisch, jeder Aufruf ist
// unabhängig, aber so bleibt die Reihenfolge nachvollziehbar.
for path in newly_created.iter().rev() {
let status = Command::new("chown")
.arg(format!("{owner}:{owner}"))
.arg(path)
.status()
.map_err(|e| Error::Other(format!("could not run chown: {e}")))?;
if !status.success() {
return Err(Error::Other(format!(
"chown failed for '{}'",
path.display()
)));
}
}
Ok(())
}
fn ensure_group_membership(pair: &DrivePair) -> Result<()> {
let Some(owner) = &pair.owner_user else {
return Ok(());
};
if pair.local.kind == MountKind::WebDav || pair.cloud.kind == MountKind::WebDav {
let status = Command::new("usermod")
.args(["-aG", "davfs2", owner])
.status()
.map_err(|e| Error::Other(format!("could not run usermod: {e}")))?;
if !status.success() {
logger_ctdra::warn(
"fstab",
&format!(
"Could not add '{owner}' to group 'davfs2' - does the group exist (package 'davfs2' installed)?"
),
);
}
}
Ok(())
}
fn write_managed_block(
pairs: &[&DrivePair],
settings: &GlobalSettings,
owner_scope: &[String],
) -> Result<()> {
let fstab_path = PathBuf::from(FSTAB_PATH);
let existing = std::fs::read_to_string(&fstab_path).unwrap_or_default();
backup(&fstab_path, &existing)?;
let without_block = crate::util::strip_managed_block(&existing, BEGIN_MARKER, END_MARKER);
let current_block =
crate::util::extract_managed_block(&existing, BEGIN_MARKER, END_MARKER).unwrap_or_default();
let new_block = merge_managed_block(&current_block, pairs, settings, owner_scope);
let new_contents = format!(
"{}\n{}\n{}\n{}\n",
without_block.trim_end(),
BEGIN_MARKER,
new_block.trim_end(),
END_MARKER
);
write_atomic(&fstab_path, &new_contents)
}
fn backup(fstab_path: &PathBuf, contents: &str) -> Result<()> {
let backup_path = PathBuf::from(format!("{FSTAB_PATH}.smart-mount.bak"));
std::fs::write(&backup_path, contents).map_err(|e| Error::io(&backup_path, e))?;
let _ = fstab_path; // nur zur Doku der Herkunft von `contents`.
Ok(())
}
/// Schreibt `contents` atomar (Temp-Datei im selben Verzeichnis + `rename`) statt per direktem
/// Trunkieren-und-Schreiben - ein Absturz oder ein volles Dateisystem mitten im Schreiben
/// könnte `/etc/fstab` sonst in einem leeren/halb geschriebenen Zustand zurücklassen, was den
/// nächsten Boot verhindern kann.
fn write_atomic(path: &Path, contents: &str) -> Result<()> {
let tmp_path = PathBuf::from(format!("{}.smart-mount-tmp", path.display()));
std::fs::write(&tmp_path, contents).map_err(|e| Error::io(&tmp_path, e))?;
std::fs::rename(&tmp_path, path).map_err(|e| Error::io(path, e))
}
/// Tag-Kommentar, der an jede von smart-mount geschriebene fstab-Zeile angehängt wird
/// (`man 5 fstab`: ein `#` leitet einen bis zum Zeilenende reichenden Kommentar ein, auch nach
/// den 6 regulären Feldern - das stört `mount(8)` nicht). Erlaubt, beim nächsten `setup fstab`
/// zeilenweise zu erkennen, zu welchem Paar/welcher Seite/welchem Besitzer eine bestehende
/// Zeile gehört, statt den kompletten Block bei jedem Lauf zu ersetzen (siehe
/// [`merge_managed_block`]).
fn line_tag(pair: &DrivePair, side: Side) -> String {
format!(
"smart-mount pair={} side={} owner={}",
pair.id,
side.as_str(),
pair.owner_user.as_deref().unwrap_or("-")
)
}
fn tagged_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result<String> {
let line = fstab_line(pair, side, settings)?;
Ok(format!("{line} # {}", line_tag(pair, side)))
}
/// Liest `(pair_id, side, owner)` aus dem von [`line_tag`] angehängten Kommentar einer
/// bestehenden fstab-Zeile, falls vorhanden.
fn parse_tag(line: &str) -> Option<(String, &'static str, String)> {
let marker = "# smart-mount ";
let idx = line.find(marker)?;
let rest = &line[idx + marker.len()..];
let mut pair_id = None;
let mut side = None;
let mut owner = None;
for token in rest.split_whitespace() {
if let Some(v) = token.strip_prefix("pair=") {
pair_id = Some(v.to_string());
} else if let Some(v) = token.strip_prefix("side=") {
side = match v {
"local" => Some("local"),
"cloud" => Some("cloud"),
_ => None,
};
} else if let Some(v) = token.strip_prefix("owner=") {
owner = Some(v.to_string());
}
}
Some((pair_id?, side?, owner.unwrap_or_else(|| "-".to_string())))
}
/// Führt den bestehenden verwalteten Block mit den frisch berechneten Zeilen für `pairs`
/// zusammen, statt ihn komplett zu ersetzen:
/// - eine bestehende Zeile, die zu einem der aktuell verarbeiteten Paare gehört, wird durch die
/// frische Version ersetzt (oder, falls deren Neuberechnung fehlschlägt, z. B. weil ein
/// MAC-adressiertes lokales Gerät gerade offline ist, unverändert beibehalten statt
/// ersatzlos gelöscht - siehe [`fstab_line`]/[`tagged_line`]);
/// - eine Zeile eines inzwischen aus der Konfiguration entfernten Paares DESSELBEN Nutzers
/// (`owner_scope`) wird entfernt;
/// - jede andere Zeile (insbesondere die eines ANDEREN Nutzers) bleibt unangetastet.
///
/// Ohne diese Unterscheidung würde ein zweiter Nutzer, der `setup fstab` für sein eigenes Konto
/// ausführt, versehentlich die vom ersten Nutzer installierten Zeilen löschen, da beide
/// denselben verwalteten Block in `/etc/fstab` teilen. Ebenso würde ein einzelnes Paar, dessen
/// Neuberechnung gerade fehlschlägt, sonst den gesamten Block-Rebuild für alle anderen,
/// gesunden Paare verhindern.
fn merge_managed_block(
current_block: &str,
pairs: &[&DrivePair],
settings: &GlobalSettings,
owner_scope: &[String],
) -> String {
let current_pair_ids: HashSet<&str> = pairs.iter().map(|p| p.id.as_str()).collect();
let mut new_lines = Vec::new();
let mut replaced: HashSet<(String, &'static str)> = HashSet::new();
for pair in pairs {
for side in [Side::Local, Side::Cloud] {
match tagged_line(pair, side, settings) {
Ok(line) => {
replaced.insert((pair.id.clone(), side.as_str()));
new_lines.push(line);
}
Err(e) => {
logger_ctdra::warn(
"fstab",
&format!(
"could not compute fstab entry for pair '{}' ({}): {e} - leaving \
any existing entry for it untouched",
pair.id,
side.as_str()
),
);
}
}
}
}
let mut kept: Vec<String> = current_block
.lines()
.filter(|line| match parse_tag(line) {
Some((pair_id, side, _)) if replaced.contains(&(pair_id.clone(), side)) => false,
Some((pair_id, _, owner))
if !current_pair_ids.contains(pair_id.as_str())
&& owner_scope.iter().any(|o| o == &owner) =>
{
false
}
_ => true,
})
.map(str::to_string)
.collect();
kept.extend(new_lines);
kept.join("\n")
}
fn fstab_line(pair: &DrivePair, side: Side, settings: &GlobalSettings) -> Result<String> {
// Wiederverwendet dieselbe Options-Berechnung wie der tatsächliche Mount-Aufruf
// (`mount::target::build_target`, inkl. `apply_owner_permissions`) - insbesondere die
// dort injizierten uid=/gid= sind hier nicht optional: laut `man mount.davfs`
// ("uid=user"/"gid=group") darf ein unprivilegierter Nutzer eine Zeile nur mounten, wenn
// uid= auf ihn selbst zeigt und er Mitglied der in gid= genannten Gruppe ist. Ohne diese
// Optionen dürfte JEDES Mitglied der Gruppe 'davfs2' JEDES konfigurierte Paar mounten,
// nicht nur der vorgesehene Besitzer (`setup()` verweigert daher bereits vorab Paare ohne
// `owner_user`).
//
// Für CIFS ist per `man mount.cifs` BESTÄTIGT, dass dieselbe Beschränkung NICHT existiert:
// uid=/gid= betreffen dort ausschließlich die simulierte Datei-Ownership nach dem Mount,
// nicht das Mount-*Recht* selbst - mount(8)/mount.cifs bieten keinen Mechanismus, eine
// 'user'-fstab-Zeile auf eine bestimmte Person einzuschränken. Für CIFS-Nutzer-Kontext-
// Paare bleibt das eine bewusst akzeptierte, strukturelle Lücke (siehe README) statt einer
// über Mount-Optionen behebbaren - die Optionen werden trotzdem gesetzt, da korrekte
// Ownership unabhängig davon nötig ist.
let target = target::build_target(pair, settings, side)?;
let kind = target::side_kind(pair, side);
let (fstype, mut extra_opts) = match kind {
MountKind::WebDav => ("davfs", String::new()),
MountKind::Smb => {
let creds = smb::credentials_path(&pair.id, side);
("cifs", format!(",credentials={}", creds.display()))
}
MountKind::Nfs => ("nfs", String::new()),
};
for opt in &target.options {
extra_opts.push_str(&format!(",{opt}"));
}
// Jede Seite bekommt ihr eigenes, eindeutiges Backing-Verzeichnis als Ziel - siehe
// Moduldoku. `pair.mount_point` selbst taucht bewusst NICHT in fstab auf.
//
// WICHTIG: die `user`-Option impliziert laut `man 8 mount` ("Non-superuser mounts") für
// JEDES Dateisystem `noexec,nosuid,nodev`, sofern nicht direkt im selben Optionslisten-
// Eintrag überschrieben. Ohne das explizite `exec` hier könnten auf einem User-Kontext-
// Laufwerk liegende Skripte NICHT ausgeführt werden. `nosuid`/`nodev` bleiben bewusst
// implizit (sinnvolle Absicherung, dafür gab es keine Anforderung).
Ok(format!(
"{source} {mount_point} {fstype} user,exec,noauto{extra_opts} 0 0",
source = target.source,
mount_point = target.mount_point.display()
))
}
fn user_config_path(username: &str) -> PathBuf {
let program_name = config_ctdra::get_program_name();
let config_name = config_ctdra::get_config_name();
let file_name = if config_name.ends_with(".toml") {
config_name
} else {
format!("{config_name}.toml")
};
user_home_dir(username)
.map(|h| h.join(".config").join(&program_name).join(&file_name))
.unwrap_or_else(|| {
PathBuf::from(format!(
"/home/{username}/.config/{program_name}/{file_name}"
))
})
}
fn user_home_dir(username: &str) -> Option<PathBuf> {
if let Ok(output) = Command::new("getent").args(["passwd", username]).output()
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
let fields: Vec<&str> = stdout.trim().split(':').collect();
if fields.len() >= 6 && !fields[5].is_empty() {
return Some(PathBuf::from(fields[5]));
}
}
if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
for line in passwd.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 6 && fields[0] == username && !fields[5].is_empty() {
return Some(PathBuf::from(fields[5]));
}
}
}
// Weder `getent` noch `/etc/passwd` konnten den Nutzer auflösen - das reine Erraten von
// `/home/<user>` kann bei einem abweichenden Home-Verzeichnis (oder falsch geschriebenem
// Nutzernamen) dazu führen, dass `setup()` anschließend die Konfigurationsdatei am
// falschen Pfad lädt und stillschweigend "nichts zu tun" meldet. Warnen statt schweigen.
logger_ctdra::warn(
"fstab",
&format!(
"could not resolve home directory for user '{username}' via getent/passwd - \
guessing '/home/{username}'"
),
);
Some(PathBuf::from(format!("/home/{username}")))
}
/// Zeigt an, dass diese Konfiguration bereits ein einmaliges `setup fstab` benötigt hat.
pub fn requires_setup(cfg: &AppConfig) -> bool {
cfg.pairs.iter().any(|p| p.context == MountContext::User)
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::Ipv4Addr;
fn sample_pair() -> DrivePair {
// Nutzt den tatsächlich ausführenden Testnutzer statt eines hartkodierten Namens,
// da `fstab_line` jetzt `id -u`/`id -g` für `owner_user` aufruft (siehe
// `mount::target::apply_owner_permissions`) - ein fester Name wäre auf anderen
// Maschinen/CI nicht garantiert vorhanden.
let user = std::env::var("USER").expect("USER env var set in test environment");
DrivePair {
id: "pair-1".into(),
name: "Test".into(),
enabled: true,
context: MountContext::User,
owner_user: Some(user.clone()),
mount_point: "/home/dragon/smart-mount/pair-1".into(),
local: crate::config::LocalSide {
kind: MountKind::Smb,
address: crate::config::LocalAddress::Ip(Ipv4Addr::new(192, 168, 1, 5)),
share: "share".into(),
username: Some("nasuser".into()),
extra_options: vec![],
},
cloud: crate::config::CloudSide {
kind: MountKind::Smb,
host_or_url: "cloud.example.com".into(),
share: "share".into(),
username: Some(user),
extra_options: vec![],
},
}
}
#[test]
fn validate_user_pairs_have_owner_rejects_missing_owner() {
let mut pair = sample_pair();
pair.owner_user = None;
let err = validate_user_pairs_have_owner(&[&pair]).unwrap_err();
assert!(err.to_string().contains("owner_user"));
}
#[test]
fn validate_user_pairs_have_owner_accepts_pair_with_owner() {
let pair = sample_pair();
assert!(validate_user_pairs_have_owner(&[&pair]).is_ok());
}
#[test]
fn renders_two_lines_per_pair_each_with_its_own_unique_target() {
let pair = sample_pair();
let block = merge_managed_block("", &[&pair], &GlobalSettings::default(), &[]);
let lines: Vec<&str> = block.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("user,exec,noauto"));
assert!(lines[0].contains("uid="));
assert!(lines[0].contains("gid="));
// Der sichtbare pair.mount_point selbst darf in KEINER Zeile als Ziel auftauchen -
// das ist der Symlink, den smart-mount zur Laufzeit umschaltet, kein fstab-Ziel.
let visible = pair.mount_point.display().to_string();
let target_tokens: Vec<&str> = [lines[0], lines[1]]
.iter()
.map(|l| l.split_whitespace().nth(1).unwrap())
.collect();
assert!(!target_tokens.contains(&visible.as_str()));
// Jede Zeile hat ein eigenes, eindeutiges Ziel (Backing-Verzeichnis) - keine zwei
// Zeilen mit demselben Mountpoint, auf dessen Disambiguierung sich mount(8) laut
// `man 8 mount` nicht verlassen ließe.
let target_of = |line: &str| line.split_whitespace().nth(1).unwrap().to_string();
assert_ne!(target_of(lines[0]), target_of(lines[1]));
assert_eq!(
target_of(lines[0]),
backing_dir(&pair, Side::Local).display().to_string()
);
assert_eq!(
target_of(lines[1]),
backing_dir(&pair, Side::Cloud).display().to_string()
);
}
#[test]
fn merge_managed_block_preserves_lines_belonging_to_other_users() {
let pair = sample_pair();
let other_users_line = "//other/share /backing/other cifs user,exec,noauto 0 0 \
# smart-mount pair=other-pair side=local owner=someone-else";
let block = merge_managed_block(
other_users_line,
&[&pair],
&GlobalSettings::default(),
&[pair.owner_user.clone().unwrap()],
);
assert!(
block.contains(other_users_line),
"a run scoped to one user must not touch another user's fstab lines"
);
assert!(block.contains("pair=pair-1"));
}
#[test]
fn merge_managed_block_drops_stale_lines_for_a_removed_pair_of_the_same_owner() {
let pair = sample_pair();
let owner = pair.owner_user.clone().unwrap();
let stale_line = format!(
"//old/share /backing/old cifs user,exec,noauto 0 0 \
# smart-mount pair=deleted-pair side=local owner={owner}"
);
// `deleted-pair` is no longer part of `pairs`, so its line should be dropped since it
// belongs to the same owner this run is scoped to - but only then.
let block =
merge_managed_block(&stale_line, &[&pair], &GlobalSettings::default(), &[owner]);
assert!(!block.contains("deleted-pair"));
assert!(block.contains("pair=pair-1"));
}
#[test]
fn merge_managed_block_keeps_stale_lines_of_a_different_owner() {
let pair = sample_pair();
let stale_line = "//old/share /backing/old cifs user,exec,noauto 0 0 \
# smart-mount pair=deleted-pair side=local owner=someone-else";
// `owner_scope` only covers `pair.owner_user`, not `someone-else` - the stale line must
// survive even though its pair is absent from `pairs`.
let block = merge_managed_block(
stale_line,
&[&pair],
&GlobalSettings::default(),
&[pair.owner_user.clone().unwrap()],
);
assert!(block.contains("deleted-pair"));
}
#[test]
fn strip_managed_block_removes_only_the_marked_section() {
let contents = "/dev/sda1 / ext4 defaults 0 1\n# BEGIN smart-mount managed block\nfoo\n# END smart-mount managed block\n";
let stripped = crate::util::strip_managed_block(contents, BEGIN_MARKER, END_MARKER);
assert!(stripped.contains("/dev/sda1"));
assert!(!stripped.contains("foo"));
}
#[test]
fn create_dir_all_owned_creates_multi_level_path_and_chowns_new_dirs() {
// `chown` zu einem ANDEREN Nutzer bräuchte Root - hier wird bewusst auf den eigenen
// Nutzer "umgechownt" (funktioniert unprivilegiert, ist ein No-op auf die tatsächliche
// Ownership, prüft aber, dass der `chown`-Aufruf pro neu angelegtem Verzeichnis
// fehlerfrei durchläuft und die Verzeichnisstruktur korrekt entsteht).
let user = std::env::var("USER").expect("USER env var set in test environment");
let base = tempfile::tempdir().expect("tempdir");
let target = base.path().join("a").join("b").join("c");
create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned");
assert!(target.is_dir());
assert!(base.path().join("a").is_dir());
}
#[test]
fn create_dir_all_owned_does_not_touch_already_existing_ancestors() {
let user = std::env::var("USER").expect("USER env var set in test environment");
let base = tempfile::tempdir().expect("tempdir");
let target = base.path().join("existing").join("new-child");
std::fs::create_dir_all(base.path().join("existing")).expect("pre-create ancestor");
// Darf nicht versuchen, `base.path()` selbst zu chownen (das existierte schon vorher) -
// nur `existing/new-child`. Schlägt fehl, falls die Funktion stattdessen versucht,
// einen nicht existierenden Nutzer für einen bereits vorhandenen Ordner zu setzen o. Ä.
create_dir_all_owned(&target, Some(&user)).expect("create_dir_all_owned");
assert!(target.is_dir());
}
#[test]
fn create_dir_all_owned_without_owner_just_creates_directories() {
let base = tempfile::tempdir().expect("tempdir");
let target = base.path().join("x").join("y");
create_dir_all_owned(&target, None).expect("create_dir_all_owned");
assert!(target.is_dir());
}
#[test]
fn user_config_path_resolves_for_user() {
let user = std::env::var("USER").expect("USER env var set in test environment");
let path = user_config_path(&user);
let s = path.to_str().unwrap();
assert!(s.contains(&format!("/home/{user}/.config/")));
assert!(s.ends_with("/config.toml"));
}
}
-2
View File
@@ -6,11 +6,9 @@ pub mod crypto;
pub mod db; pub mod db;
pub mod doctor; pub mod doctor;
pub mod error; pub mod error;
pub mod fstab;
pub mod mount; pub mod mount;
pub mod network; pub mod network;
pub mod reconcile; pub mod reconcile;
pub mod systemd; pub mod systemd;
pub(crate) mod util;
pub use error::{Error, Result}; pub use error::{Error, Result};
+6 -19
View File
@@ -18,21 +18,9 @@ use crate::config::{DrivePair, GlobalSettings, MountKind};
use crate::db::credentials::{Credential, Side}; use crate::db::credentials::{Credential, Side};
use crate::error::Result; use crate::error::Result;
/// Wie ein Mount-Aufruf ausgeführt wird.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MountInvocation {
/// Root, volle `-o`-Optionen: `mount -t <type> <source> <target> -o <opts>`.
Direct,
/// Unprivilegiert über eine passende `user,noauto`-fstab-Zeile: `mount <target>` (nur das
/// Ziel, genau der in `man 8 mount` dokumentierte Fall `mount /cd`). Da jede Seite ihr
/// eigenes, eindeutiges Backing-Verzeichnis hat (siehe [`target::backing_dir`]), gibt es
/// dabei nie mehr als eine passende fstab-Zeile. Voraussetzung: `smart-mount setup fstab`
/// wurde für dieses Paar bereits ausgeführt.
ViaFstab,
}
/// Alle Informationen, die ein Backend braucht, um eine Seite eines Laufwerkspaars ein- /// Alle Informationen, die ein Backend braucht, um eine Seite eines Laufwerkspaars ein-
/// bzw. auszuhängen. /// 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 struct MountTarget {
pub pair_id: String, pub pair_id: String,
/// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile /// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile
@@ -41,12 +29,11 @@ pub struct MountTarget {
pub mount_point: PathBuf, pub mount_point: PathBuf,
/// Vollständiger Quellstring, z. B. `//server/share`, `https://host/path`, `server:/export`. /// Vollständiger Quellstring, z. B. `//server/share`, `https://host/path`, `server:/export`.
pub source: String, pub source: String,
/// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`), nur bei /// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`) - mit Kommas
/// `MountInvocation::Direct` verwendet - mit Kommas verbindbar für `mount -o`. /// verbindbar für `mount -o`.
pub options: Vec<String>, pub options: Vec<String>,
pub invocation: MountInvocation, /// Für Paare mit gesetztem `owner_user`: der Linux-Benutzername, dem Zugangsdaten-/
/// Für `MountContext::User`-Paare: der Linux-Benutzername, dem Zugangsdaten-/Secrets- /// Secrets-Dateien gehören sollen.
/// Dateien gehören sollen.
pub owner_user: Option<String>, pub owner_user: Option<String>,
} }
+7 -16
View File
@@ -5,9 +5,7 @@ use std::process::Command;
use crate::db::credentials::Credential; use crate::db::credentials::Credential;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::mount::{ use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
};
pub struct NfsBackend; pub struct NfsBackend;
@@ -34,19 +32,12 @@ impl MountBackend for NfsBackend {
fn mount(&self, target: &MountTarget) -> Result<()> { fn mount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("mount"); let mut cmd = Command::new("mount");
match target.invocation { cmd.arg("-t")
MountInvocation::Direct => { .arg("nfs")
cmd.arg("-t") .arg(&target.source)
.arg("nfs") .arg(&target.mount_point);
.arg(&target.source) if !target.options.is_empty() {
.arg(&target.mount_point); cmd.arg("-o").arg(target.options.join(","));
if !target.options.is_empty() {
cmd.arg("-o").arg(target.options.join(","));
}
}
MountInvocation::ViaFstab => {
cmd.arg(&target.mount_point);
}
} }
run_tolerating_already_done(cmd, "nfs mount", false) run_tolerating_already_done(cmd, "nfs mount", false)
} }
+13 -22
View File
@@ -7,9 +7,7 @@ use std::process::Command;
use crate::db::credentials::Credential; use crate::db::credentials::Credential;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::mount::{ use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
};
pub struct SmbBackend; pub struct SmbBackend;
@@ -38,23 +36,16 @@ impl MountBackend for SmbBackend {
fn mount(&self, target: &MountTarget) -> Result<()> { fn mount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("mount"); let mut cmd = Command::new("mount");
match target.invocation { cmd.arg("-t")
MountInvocation::Direct => { .arg("cifs")
cmd.arg("-t") .arg(&target.source)
.arg("cifs") .arg(&target.mount_point);
.arg(&target.source) let mut opts = target.options.clone();
.arg(&target.mount_point); opts.push(format!(
let mut opts = target.options.clone(); "credentials={}",
opts.push(format!( credentials_path(&target.pair_id, target.side).display()
"credentials={}", ));
credentials_path(&target.pair_id, target.side).display() cmd.arg("-o").arg(opts.join(","));
));
cmd.arg("-o").arg(opts.join(","));
}
MountInvocation::ViaFstab => {
cmd.arg(&target.mount_point);
}
}
run_tolerating_already_done(cmd, "cifs mount", false) run_tolerating_already_done(cmd, "cifs mount", false)
} }
@@ -65,8 +56,8 @@ impl MountBackend for SmbBackend {
} }
} }
/// Stabiler Pfad (nicht ein Tempfile!), da bei `MountInvocation::ViaFstab` die fstab-Zeile /// Stabiler Pfad (nicht ein Tempfile!) - `prepare()` schreibt hierhin, `mount()` referenziert
/// (von `setup fstab` einmalig geschrieben) exakt auf diesen `credentials=`-Pfad verweist. /// denselben Pfad über `-o credentials=...`.
pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf { pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf {
let base = config_ctdra::get_config_path() let base = config_ctdra::get_config_path()
.parent() .parent()
+2 -8
View File
@@ -17,10 +17,10 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountContext, MountKind}; use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide, MountKind};
use crate::db::credentials::Side; use crate::db::credentials::Side;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::mount::{self, MountInvocation, MountTarget}; use crate::mount::{self, MountTarget};
use crate::network::address; use crate::network::address;
/// Eindeutiges Backing-Verzeichnis für eine Seite eines Paars - hier (und nur hier) wird /// Eindeutiges Backing-Verzeichnis für eine Seite eines Paars - hier (und nur hier) wird
@@ -60,10 +60,6 @@ pub fn build_target_with_cached_local_ip(
side: Side, side: Side,
cached_local_ip: Option<std::net::Ipv4Addr>, cached_local_ip: Option<std::net::Ipv4Addr>,
) -> Result<MountTarget> { ) -> Result<MountTarget> {
let invocation = match pair.context {
MountContext::System => MountInvocation::Direct,
MountContext::User => MountInvocation::ViaFstab,
};
let kind = side_kind(pair, side); let kind = side_kind(pair, side);
let (source, mut options) = match side { let (source, mut options) = match side {
@@ -88,7 +84,6 @@ pub fn build_target_with_cached_local_ip(
mount_point: backing_dir(pair, side), mount_point: backing_dir(pair, side),
source, source,
options, options,
invocation,
owner_user: pair.owner_user.clone(), owner_user: pair.owner_user.clone(),
}) })
} }
@@ -296,7 +291,6 @@ mod tests {
id: "pair-1".into(), id: "pair-1".into(),
name: "Test".into(), name: "Test".into(),
enabled: true, enabled: true,
context: MountContext::System,
owner_user: None, owner_user: None,
mount_point: "/media/smart-mount/pair-1".into(), mount_point: "/media/smart-mount/pair-1".into(),
local: LocalSide { local: LocalSide {
+11 -32
View File
@@ -7,9 +7,7 @@ use std::process::Command;
use crate::db::credentials::Credential; use crate::db::credentials::Credential;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::mount::{ use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done};
MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done,
};
pub struct WebDavBackend; pub struct WebDavBackend;
@@ -60,19 +58,12 @@ impl MountBackend for WebDavBackend {
fn mount(&self, target: &MountTarget) -> Result<()> { fn mount(&self, target: &MountTarget) -> Result<()> {
let mut cmd = Command::new("mount"); let mut cmd = Command::new("mount");
match target.invocation { cmd.arg("-t")
MountInvocation::Direct => { .arg("davfs")
cmd.arg("-t") .arg(&target.source)
.arg("davfs") .arg(&target.mount_point);
.arg(&target.source) if !target.options.is_empty() {
.arg(&target.mount_point); cmd.arg("-o").arg(target.options.join(","));
if !target.options.is_empty() {
cmd.arg("-o").arg(target.options.join(","));
}
}
MountInvocation::ViaFstab => {
cmd.arg(&target.mount_point);
}
} }
run_tolerating_already_done(cmd, "davfs2 mount", false) run_tolerating_already_done(cmd, "davfs2 mount", false)
} }
@@ -84,26 +75,14 @@ impl MountBackend for WebDavBackend {
} }
} }
/// 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 { fn davfs2_conf_path() -> PathBuf {
if sudo_ctdra::is_run_as_root() { PathBuf::from("/etc/davfs2/davfs2.conf")
PathBuf::from("/etc/davfs2/davfs2.conf")
} else {
home_dir().join(".davfs2/davfs2.conf")
}
} }
pub(crate) fn davfs2_secrets_path() -> PathBuf { pub(crate) fn davfs2_secrets_path() -> PathBuf {
if sudo_ctdra::is_run_as_root() { PathBuf::from("/etc/davfs2/secrets")
PathBuf::from("/etc/davfs2/secrets")
} else {
home_dir().join(".davfs2/secrets")
}
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
} }
/// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen /// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen
+1 -6
View File
@@ -354,10 +354,6 @@ async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -
source: String::new(), source: String::new(),
mount_point: target::backing_dir(pair, side), mount_point: target::backing_dir(pair, side),
options: vec![], options: vec![],
invocation: match pair.context {
crate::config::MountContext::System => crate::mount::MountInvocation::Direct,
crate::config::MountContext::User => crate::mount::MountInvocation::ViaFstab,
},
owner_user: pair.owner_user.clone(), owner_user: pair.owner_user.clone(),
} }
}); });
@@ -367,7 +363,7 @@ async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) -
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::config::{CloudSide, MountContext, MountKind}; use crate::config::{CloudSide, MountKind};
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
fn sample_pair(local_kind: MountKind, cloud_kind: MountKind) -> DrivePair { fn sample_pair(local_kind: MountKind, cloud_kind: MountKind) -> DrivePair {
@@ -375,7 +371,6 @@ mod tests {
id: "pair-1".into(), id: "pair-1".into(),
name: "Test".into(), name: "Test".into(),
enabled: true, enabled: true,
context: MountContext::System,
owner_user: None, owner_user: None,
mount_point: "/media/smart-mount/pair-1".into(), mount_point: "/media/smart-mount/pair-1".into(),
local: LocalSide { local: LocalSide {
+35 -294
View File
@@ -1,30 +1,23 @@
//! Generiert/installiert systemd-Units (System- und User-Kontext) sowie das Crontab-Äquivalent //! Cron-Fallback (`smart-mount service crontab`) für Systeme ohne (oder ohne genutzten)
//! für Systeme ohne systemd. //! systemd.
//! //!
//! Die systemd-Units rufen ausschließlich einfache `smart-mount`-Subcommands auf, damit //! Die eigentliche systemd-Einrichtung passiert NICHT mehr zur Laufzeit über dieses Modul,
//! dieselben Zeilen 1:1 als Crontab-Einträge funktionieren. //! 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::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
const MOUNT_SERVICE: &str = "smart-mount-mount.service";
const WATCH_SERVICE: &str = "smart-mount-watch.service";
const WATCH_TIMER: &str = "smart-mount-watch.timer";
const CRON_D_PATH: &str = "/etc/cron.d/smart-mount"; const CRON_D_PATH: &str = "/etc/cron.d/smart-mount";
const CRON_BEGIN_MARKER: &str = "# BEGIN smart-mount managed block"; const CRON_BEGIN_MARKER: &str = "# BEGIN smart-mount managed block";
const CRON_END_MARKER: &str = "# END smart-mount managed block"; const CRON_END_MARKER: &str = "# END smart-mount managed block";
/// System (root) oder User-Kontext für die Unit-Installation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
System,
User,
}
fn binary_path() -> String { fn binary_path() -> String {
std::env::current_exe() std::env::current_exe()
.ok() .ok()
@@ -32,141 +25,15 @@ fn binary_path() -> String {
.unwrap_or_else(|| "/usr/bin/smart-mount".to_string()) .unwrap_or_else(|| "/usr/bin/smart-mount".to_string())
} }
fn mount_service_unit(scope: Scope) -> String { /// Ob `systemctl` auf diesem System vorhanden ist - rein informativ für `doctor`; die
let wanted_by = match scope { /// eigentliche systemd-Einrichtung läuft über die Paketierung (siehe Moduldoku).
Scope::System => "multi-user.target",
Scope::User => "default.target",
};
// `network-online.target` ist ein Ziel des System-Managers - unter `systemctl --user` gibt
// es dafür keine sinnvolle Entsprechung (die Unit existiert dort nicht bzw. wird nie
// erreicht), die Ordnungsabhängigkeit wäre also für User-Scope-Units wirkungslos statt
// schlicht harmlos. Nur im System-Kontext gesetzt.
let network_wait = match scope {
Scope::System => "After=network-online.target\nWants=network-online.target\n",
Scope::User => "",
};
format!(
"[Unit]\nDescription=smart-mount: mount configured drive pairs at boot\n{network_wait}\n[Service]\nType=oneshot\nExecStart={} mount --all\n\n[Install]\nWantedBy={wanted_by}\n",
binary_path()
)
}
fn watch_service_unit() -> String {
format!(
"[Unit]\nDescription=smart-mount: check reachability and switch local/cloud if needed\n\n[Service]\nType=oneshot\nExecStart={} watch\n",
binary_path()
)
}
fn watch_timer_unit(interval_secs: u64) -> String {
format!(
"[Unit]\nDescription=smart-mount: periodic reconciling\n\n[Timer]\nOnBootSec=1min\nOnUnitActiveSec={interval_secs}s\nPersistent=true\nUnit={WATCH_SERVICE}\n\n[Install]\nWantedBy=timers.target\n"
)
}
fn unit_dir(scope: Scope) -> Result<PathBuf> {
match scope {
Scope::System => {
if !sudo_ctdra::is_run_as_root() {
return Err(Error::RequiresRoot("service install --system"));
}
Ok(PathBuf::from("/etc/systemd/system"))
}
Scope::User => {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or(Error::Other("HOME not set".to_string()))?;
Ok(home.join(".config/systemd/user"))
}
}
}
fn systemctl(scope: Scope, args: &[&str]) -> Result<()> {
let mut cmd = Command::new("systemctl");
if scope == Scope::User {
cmd.arg("--user");
}
cmd.args(args);
let output = cmd
.output()
.map_err(|e| Error::Other(format!("could not run systemctl: {e}")))?;
if !output.status.success() {
return Err(Error::Other(format!(
"systemctl {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
)));
}
Ok(())
}
/// Schreibt die Unit-Dateien, lädt systemd neu und aktiviert Mount- und Watch-Timer-Unit.
pub fn install(scope: Scope, watch_interval_secs: u64) -> Result<()> {
let dir = unit_dir(scope)?;
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
std::fs::write(dir.join(MOUNT_SERVICE), mount_service_unit(scope))
.map_err(|e| Error::io(&dir, e))?;
std::fs::write(dir.join(WATCH_SERVICE), watch_service_unit())
.map_err(|e| Error::io(&dir, e))?;
std::fs::write(dir.join(WATCH_TIMER), watch_timer_unit(watch_interval_secs))
.map_err(|e| Error::io(&dir, e))?;
systemctl(scope, &["daemon-reload"])?;
systemctl(scope, &["enable", "--now", MOUNT_SERVICE, WATCH_TIMER])?;
if scope == Scope::User {
logger_ctdra::info(
"systemd",
"For boot-time operation without an active login session: run 'loginctl enable-linger <user>'. \
Note: in that case the OS keyring may not yet be available at boot - \
smart-mount then automatically falls back to the key file.",
);
}
Ok(())
}
/// Ob `systemctl` auf diesem System überhaupt vorhanden ist - Voraussetzung, bevor
/// [`install`]/[`uninstall`] sinnvoll aufgerufen werden können.
pub fn is_available() -> bool { pub fn is_available() -> bool {
crate::mount::binary_available("systemctl") crate::mount::binary_available("systemctl")
} }
/// Ergebnis von [`uninstall`]. /// Erzeugt die Crontab-Äquivalente zu den paketierten systemd-Units, für Systeme ohne
pub enum SystemdUninstallOutcome { /// (genutzten) systemd. `watch_interval_secs` ist derselbe Wert wie
/// Mindestens eine Unit-Datei war vorhanden und wurde entfernt. /// `settings.watch_interval_secs`.
Removed,
/// Keine der Unit-Dateien war vorhanden - nichts zu tun.
NotPresent,
}
/// Deaktiviert und entfernt die Unit-Dateien, falls vorhanden.
pub fn uninstall(scope: Scope) -> Result<SystemdUninstallOutcome> {
let dir = unit_dir(scope)?;
let units = [MOUNT_SERVICE, WATCH_SERVICE, WATCH_TIMER];
if !units.iter().any(|unit| dir.join(unit).exists()) {
return Ok(SystemdUninstallOutcome::NotPresent);
}
let _ = systemctl(scope, &["disable", "--now", MOUNT_SERVICE, WATCH_TIMER]);
for unit in units {
let path = dir.join(unit);
if path.exists() {
std::fs::remove_file(&path).map_err(|e| Error::io(&path, e))?;
}
}
systemctl(scope, &["daemon-reload"])?;
Ok(SystemdUninstallOutcome::Removed)
}
/// Erzeugt die Crontab-Äquivalente zu den generierten Units, für Systeme ohne systemd.
/// `watch_interval_secs` ist derselbe Wert wie `settings.watch_interval_secs`, der auch die
/// `OnUnitActiveSec`-Periode des systemd-Timers steuert - beide Wege sollen dieselbe Kadenz
/// ergeben, statt dass die Crontab-Variante einen unabhängigen, fest eingebauten Wert hat.
pub fn crontab_equivalent(watch_interval_secs: u64) -> String { pub fn crontab_equivalent(watch_interval_secs: u64) -> String {
let bin = binary_path(); let bin = binary_path();
let schedule = cron_schedule_for_interval(watch_interval_secs); let schedule = cron_schedule_for_interval(watch_interval_secs);
@@ -177,50 +44,22 @@ pub fn crontab_equivalent(watch_interval_secs: u64) -> String {
pub enum CronInstallOutcome { pub enum CronInstallOutcome {
/// Systemweiter Eintrag geschrieben (`/etc/cron.d/smart-mount`). /// Systemweiter Eintrag geschrieben (`/etc/cron.d/smart-mount`).
SystemFile(PathBuf), SystemFile(PathBuf),
/// Persönliche Crontab des aufrufenden Nutzers aktualisiert.
UserCrontab,
/// Kein Cron-Mechanismus auf diesem System gefunden - nichts geschrieben, der Aufrufer /// Kein Cron-Mechanismus auf diesem System gefunden - nichts geschrieben, der Aufrufer
/// sollte stattdessen [`crontab_equivalent`] anzeigen. /// sollte stattdessen [`crontab_equivalent`] anzeigen.
Unavailable, Unavailable,
} }
/// Richtet die periodische Ausführung direkt über Cron ein (Alternative zu [`install`] für /// Richtet die periodische Ausführung direkt über `/etc/cron.d/smart-mount` ein - manueller
/// Systeme ohne systemd), sofern ein Cron-Mechanismus gefunden wird - sonst [`CronInstallOutcome::Unavailable`] /// Fallback für Systeme ohne (genutzten) systemd, sofern `/etc/cron.d` existiert - sonst
/// statt eines Fehlers, der Aufrufer zeigt dann [`crontab_equivalent`] zur manuellen Einrichtung. /// [`CronInstallOutcome::Unavailable`] statt eines Fehlers, der Aufrufer zeigt dann
/// /// [`crontab_equivalent`] zur manuellen Einrichtung. Erfordert Root (siehe Moduldoku) - der
/// `Scope::System` schreibt `/etc/cron.d/smart-mount` (Standard-Konvention für /// Aufrufer (`cli::service`) prüft das bereits vorab.
/// paketverwaltete Cron-Einträge, läuft als root; erfordert Root-Rechte, kein pub fn install_cron(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
/// Self-Elevate - analog zu `install(Scope::System, ...)`). `Scope::User` aktualisiert die
/// persönliche Crontab des aufrufenden Nutzers über `crontab -l`/`crontab -`, mit demselben
/// verwalteten-Block-Muster wie `fstab::setup` für `/etc/fstab` - bestehende, unabhängige
/// Cron-Einträge bleiben unangetastet.
pub fn install_cron(scope: Scope, watch_interval_secs: u64) -> Result<CronInstallOutcome> {
match scope {
Scope::System => install_cron_system(watch_interval_secs),
Scope::User => install_cron_user(watch_interval_secs),
}
}
fn managed_cron_block(watch_interval_secs: u64, user_field: Option<&str>) -> String {
let bin = binary_path();
let schedule = cron_schedule_for_interval(watch_interval_secs);
let user_prefix = user_field.map(|u| format!("{u} ")).unwrap_or_default();
format!(
"{CRON_BEGIN_MARKER}\n@reboot {user_prefix}{bin} mount --all\n{schedule} {user_prefix}{bin} watch\n{CRON_END_MARKER}\n"
)
}
fn install_cron_system(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
if !sudo_ctdra::is_run_as_root() {
return Err(Error::RequiresRoot("service crontab (system context)"));
}
if !Path::new("/etc/cron.d").is_dir() { if !Path::new("/etc/cron.d").is_dir() {
return Ok(CronInstallOutcome::Unavailable); return Ok(CronInstallOutcome::Unavailable);
} }
// /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root, let contents = managed_cron_block(watch_interval_secs);
// passend dazu, dass System-Kontext-Paare auch sonst als root gemountet werden.
let contents = managed_cron_block(watch_interval_secs, Some("root"));
std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?; std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?;
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -231,19 +70,14 @@ fn install_cron_system(watch_interval_secs: u64) -> Result<CronInstallOutcome> {
Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH))) Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH)))
} }
fn install_cron_user(watch_interval_secs: u64) -> Result<CronInstallOutcome> { fn managed_cron_block(watch_interval_secs: u64) -> String {
if !crate::mount::binary_available("crontab") { let bin = binary_path();
return Ok(CronInstallOutcome::Unavailable); 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.
let existing = read_current_user_crontab(); format!(
let without_block = "{CRON_BEGIN_MARKER}\n@reboot root {bin} mount --all\n{schedule} root {bin} watch\n{CRON_END_MARKER}\n"
crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER); )
let block = managed_cron_block(watch_interval_secs, None);
let new_contents = format!("{}\n{block}", without_block.trim_end());
write_user_crontab(&new_contents)?;
Ok(CronInstallOutcome::UserCrontab)
} }
/// Ergebnis von [`uninstall_cron`]. /// Ergebnis von [`uninstall_cron`].
@@ -255,22 +89,8 @@ pub enum CronUninstallOutcome {
} }
/// Gegenstück zu [`install_cron`]: entfernt einen zuvor über `install_cron` angelegten /// Gegenstück zu [`install_cron`]: entfernt einen zuvor über `install_cron` angelegten
/// Cron-Eintrag wieder, sofern vorhanden. `Scope::User` rührt dabei - wie `install_cron` - /// Cron-Eintrag wieder, sofern vorhanden.
/// nur den von smart-mount verwalteten Block in der persönlichen Crontab an, keine pub fn uninstall_cron() -> Result<CronUninstallOutcome> {
/// unabhängigen, bereits vorhandenen Einträge.
pub fn uninstall_cron(scope: Scope) -> Result<CronUninstallOutcome> {
match scope {
Scope::System => uninstall_cron_system(),
Scope::User => uninstall_cron_user(),
}
}
fn uninstall_cron_system() -> Result<CronUninstallOutcome> {
if !sudo_ctdra::is_run_as_root() {
return Err(Error::RequiresRoot(
"service uninstall (system context, cron)",
));
}
let path = Path::new(CRON_D_PATH); let path = Path::new(CRON_D_PATH);
if !path.exists() { if !path.exists() {
return Ok(CronUninstallOutcome::NotPresent); return Ok(CronUninstallOutcome::NotPresent);
@@ -279,53 +99,6 @@ fn uninstall_cron_system() -> Result<CronUninstallOutcome> {
Ok(CronUninstallOutcome::Removed) Ok(CronUninstallOutcome::Removed)
} }
fn uninstall_cron_user() -> Result<CronUninstallOutcome> {
if !crate::mount::binary_available("crontab") {
return Ok(CronUninstallOutcome::NotPresent);
}
let existing = read_current_user_crontab();
if !existing.contains(CRON_BEGIN_MARKER) {
return Ok(CronUninstallOutcome::NotPresent);
}
let without_block =
crate::util::strip_managed_block(&existing, CRON_BEGIN_MARKER, CRON_END_MARKER);
write_user_crontab(without_block.trim_end())?;
Ok(CronUninstallOutcome::Removed)
}
/// `crontab -l` meldet für einen Nutzer ohne bestehende Crontab einen Fehler ("no crontab for
/// ...") - das ist der Normalfall bei der ersten Einrichtung, kein echter Fehler.
fn read_current_user_crontab() -> String {
Command::new("crontab")
.arg("-l")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default()
}
fn write_user_crontab(contents: &str) -> Result<()> {
let mut child = Command::new("crontab")
.arg("-")
.stdin(Stdio::piped())
.spawn()
.map_err(|e| Error::Other(format!("could not start 'crontab': {e}")))?;
child
.stdin
.take()
.ok_or_else(|| Error::Other("stdin of 'crontab -' not available".to_string()))?
.write_all(contents.as_bytes())
.map_err(|e| Error::Other(format!("writing to 'crontab -' failed: {e}")))?;
let status = child
.wait()
.map_err(|e| Error::Other(format!("'crontab -' failed: {e}")))?;
if !status.success() {
return Err(Error::Other("'crontab -' reported an error".to_string()));
}
Ok(())
}
/// Rechnet ein Sekunden-Intervall in einen `*/N`-artigen Cron-Ausdruck um. Crons Granularität /// 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 /// 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 /// (Cron kann nicht häufiger als minütlich auslösen). Ab 60 Minuten wird auf Stunden
@@ -350,44 +123,12 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn mount_service_unit_system_scope_uses_multi_user_target() { fn managed_cron_block_includes_root_user_field_and_both_lines() {
let unit = mount_service_unit(Scope::System); let block = managed_cron_block(120);
assert!(unit.contains("WantedBy=multi-user.target"));
}
#[test]
fn mount_service_unit_user_scope_uses_default_target() {
let unit = mount_service_unit(Scope::User);
assert!(unit.contains("WantedBy=default.target"));
assert!(!unit.contains("WantedBy=multi-user.target"));
}
#[test]
fn mount_service_unit_system_scope_waits_for_network_online() {
let unit = mount_service_unit(Scope::System);
assert!(unit.contains("network-online.target"));
}
#[test]
fn mount_service_unit_user_scope_does_not_reference_network_online_target() {
let unit = mount_service_unit(Scope::User);
assert!(!unit.contains("network-online.target"));
}
#[test]
fn managed_cron_block_for_user_crontab_has_no_user_field() {
let block = managed_cron_block(120, None);
assert!(block.starts_with(CRON_BEGIN_MARKER)); assert!(block.starts_with(CRON_BEGIN_MARKER));
assert!(block.trim_end().ends_with(CRON_END_MARKER)); assert!(block.trim_end().ends_with(CRON_END_MARKER));
assert!(block.contains("@reboot") && !block.contains("@reboot root"));
assert!(block.contains("mount --all"));
assert!(block.contains("*/2 * * * *"));
}
#[test]
fn managed_cron_block_for_system_cron_d_includes_user_field() {
let block = managed_cron_block(120, Some("root"));
assert!(block.contains("@reboot root ")); assert!(block.contains("@reboot root "));
assert!(block.contains("mount --all"));
assert!(block.contains("*/2 * * * * root ")); assert!(block.contains("*/2 * * * * root "));
} }
-103
View File
@@ -1,103 +0,0 @@
//! Kleine, modulübergreifend geteilte Hilfsfunktionen.
/// Entfernt einen durch `begin_marker`/`end_marker` abgegrenzten Abschnitt aus `contents`
/// (Marker-Zeilen selbst eingeschlossen). Für das "verwalteter Block"-Muster, mit dem
/// smart-mount eigene Zeilen in einer fremden Datei (`/etc/fstab`, Crontab) aktualisiert,
/// ohne bestehende, unabhängige Einträge anzurühren - siehe [`crate::fstab`] und
/// [`crate::systemd`].
pub(crate) fn strip_managed_block(contents: &str, begin_marker: &str, end_marker: &str) -> String {
let mut out = String::new();
let mut inside = false;
for line in contents.lines() {
if line.trim() == begin_marker {
inside = true;
continue;
}
if line.trim() == end_marker {
inside = false;
continue;
}
if !inside {
out.push_str(line);
out.push('\n');
}
}
out
}
/// Gegenstück zu [`strip_managed_block`]: gibt nur den Inhalt *innerhalb* des Blocks zurück
/// (ohne die Marker-Zeilen selbst), oder `None`, falls kein solcher Block vorhanden ist. Für
/// ein zeilenweises Zusammenführen (statt komplettem Ersetzen) des verwalteten Blocks.
pub(crate) fn extract_managed_block(
contents: &str,
begin_marker: &str,
end_marker: &str,
) -> Option<String> {
let mut out = String::new();
let mut inside = false;
let mut found = false;
for line in contents.lines() {
if line.trim() == begin_marker {
inside = true;
found = true;
continue;
}
if line.trim() == end_marker {
inside = false;
continue;
}
if inside {
out.push_str(line);
out.push('\n');
}
}
found.then_some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removes_only_the_marked_section() {
let contents = "line1\n# BEGIN test\nfoo\nbar\n# END test\nline2\n";
let stripped = strip_managed_block(contents, "# BEGIN test", "# END test");
assert_eq!(stripped, "line1\nline2\n");
}
#[test]
fn is_a_noop_when_markers_are_absent() {
let contents = "line1\nline2\n";
assert_eq!(
strip_managed_block(contents, "# BEGIN test", "# END test"),
contents
);
}
#[test]
fn handles_content_before_the_first_marker_and_no_trailing_content() {
let contents = "keep-me\n# BEGIN x\ndrop-me\n# END x\n";
assert_eq!(
strip_managed_block(contents, "# BEGIN x", "# END x"),
"keep-me\n"
);
}
#[test]
fn extract_managed_block_returns_only_the_interior() {
let contents = "line1\n# BEGIN test\nfoo\nbar\n# END test\nline2\n";
assert_eq!(
extract_managed_block(contents, "# BEGIN test", "# END test"),
Some("foo\nbar\n".to_string())
);
}
#[test]
fn extract_managed_block_is_none_when_markers_are_absent() {
let contents = "line1\nline2\n";
assert_eq!(
extract_managed_block(contents, "# BEGIN test", "# END test"),
None
);
}
}