diff --git a/Cargo.lock b/Cargo.lock index 561d0f8..3aa82ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -178,17 +178,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "arc-swap" version = "1.9.2" @@ -224,143 +213,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "autocfg" version = "1.5.1" @@ -436,28 +288,6 @@ dependencies = [ "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]] name = "branches" version = "0.4.6" @@ -505,15 +335,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "cc" version = "1.4.6" @@ -714,22 +535,6 @@ dependencies = [ "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]] name = "core-foundation-sys" version = "0.8.7" @@ -860,18 +665,6 @@ dependencies = [ "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]] name = "displaydoc" version = "0.2.7" @@ -895,33 +688,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "env_filter" version = "2.0.0" @@ -969,26 +735,6 @@ dependencies = [ "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]] name = "fallible-iterator" version = "0.3.0" @@ -1030,36 +776,6 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "futures-task" version = "0.3.34" @@ -1073,7 +789,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", - "futures-macro", "futures-task", "pin-project-lite", "slab", @@ -1203,24 +918,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "home" version = "0.5.12" @@ -1453,7 +1150,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "block-padding", "hybrid-array", ] @@ -1518,27 +1214,6 @@ dependencies = [ "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]] name = "lazy_static" version = "1.5.0" @@ -1755,20 +1430,6 @@ dependencies = [ "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]] name = "num-bigint" version = "0.4.8" @@ -1779,15 +1440,6 @@ dependencies = [ "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]] name = "num-conv" version = "0.2.2" @@ -1803,27 +1455,6 @@ dependencies = [ "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]] name = "num-traits" version = "0.2.19" @@ -1860,16 +1491,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "owo-colors" version = "3.5.0" @@ -1885,12 +1506,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.5" @@ -1926,17 +1541,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "polling" version = "3.11.0" @@ -2010,15 +1614,6 @@ dependencies = [ "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]] name = "proc-macro2" version = "1.0.107" @@ -2307,48 +1902,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "semver" version = "1.0.28" @@ -2398,17 +1951,6 @@ dependencies = [ "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]] name = "serde_spanned" version = "1.1.1" @@ -2424,17 +1966,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "sharded-slab" version = "0.1.7" @@ -2530,7 +2061,6 @@ dependencies = [ "config-ctdra", "dialoguer", "getrandom 0.4.3", - "keyring", "logger-ctdra", "program-ctdra", "serde", @@ -2803,18 +2333,6 @@ dependencies = [ "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]] name = "toml_parser" 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" 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]] name = "uncased" version = "0.9.10" @@ -3195,7 +2702,6 @@ checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ "getrandom 0.4.3", "js-sys", - "serde_core", "sha1_smol", "wasm-bindgen", ] @@ -3325,19 +2831,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-result" version = "0.4.1" @@ -3449,9 +2942,6 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] [[package]] name = "wit-bindgen" @@ -3503,87 +2993,6 @@ dependencies = [ "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]] name = "zerocopy" version = "0.8.57" @@ -3671,44 +3080,3 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" 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", -] diff --git a/Cargo.toml b/Cargo.toml index 60dc93d..d886009 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,6 @@ serde_json = "1" thiserror = "2" anyhow = "1" aes-gcm = "0.11.1" -keyring = "4.2.0" dialoguer = "0.12.0" uuid = { version = "1", features = ["v4"] } # 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 Cloud-Laufwerk. Ein Watchdog prüft periodisch die Erreichbarkeit und schaltet bei Bedarf zwischen beiden um. Zugangsdaten werden verschlüsselt in einer lokalen Turso-Datenbank -gespeichert; smart-mount 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 = [ ["target/release/smart-mount", "usr/bin/smart-mount", "755"], ["README.md", "usr/share/doc/smart-mount/README.md", "644"], ["LICENSE", "usr/share/doc/smart-mount/copyright", "644"], + ["packaging/systemd/smart-mount-mount.service", "usr/lib/systemd/system/smart-mount-mount.service", "644"], + ["packaging/systemd/smart-mount-watch.service", "usr/lib/systemd/system/smart-mount-watch.service", "644"], + ["packaging/systemd/smart-mount-watch.timer", "usr/lib/systemd/system/smart-mount-watch.timer", "644"], ] [package.metadata.generate-rpm] @@ -71,9 +75,27 @@ assets = [ { source = "target/release/smart-mount", dest = "/usr/bin/smart-mount", mode = "755" }, { source = "README.md", dest = "/usr/share/doc/smart-mount/README.md", mode = "644", doc = true }, { source = "LICENSE", dest = "/usr/share/licenses/smart-mount/LICENSE", mode = "644", license = true }, + { source = "packaging/systemd/smart-mount-mount.service", dest = "/usr/lib/systemd/system/smart-mount-mount.service", mode = "644" }, + { source = "packaging/systemd/smart-mount-watch.service", dest = "/usr/lib/systemd/system/smart-mount-watch.service", mode = "644" }, + { source = "packaging/systemd/smart-mount-watch.timer", dest = "/usr/lib/systemd/system/smart-mount-watch.timer", mode = "644" }, ] requires = { "mac2ip" = "*", "nmap" = "*" } suggests = { "davfs2" = "*", "cifs-utils" = "*", "nfs-utils" = "*" } +# postinst/postrm-Äquivalente: richten den paketierten systemd-Dienst automatisch ein/entfernen +# (Gegenstück zu packaging/deb/postinst+postrm). $1 in %postun: Anzahl verbleibender Versionen +# nach diesem Schritt - 0 nur bei vollständiger Deinstallation, nicht bei einem Upgrade. +post_install_script = """ +if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl enable --now smart-mount-mount.service smart-mount-watch.timer || true +fi +""" +post_uninstall_script = """ +if [ "$1" = "0" ] && command -v systemctl >/dev/null 2>&1; then + systemctl disable --now smart-mount-mount.service smart-mount-watch.timer smart-mount-watch.service || true + systemctl daemon-reload || true +fi +""" [package.metadata.arch] pkgrel = "1" # Wird in CI durch get-build-number.py dynamisch überschrieben diff --git a/README.md b/README.md index 1e32030..49e92d9 100644 --- a/README.md +++ b/README.md @@ -24,28 +24,31 @@ werden (z. B. über ein separates Sync-Tool). - `davfs2` für WebDAV-Laufwerke - `cifs-utils` für SMB/CIFS-Laufwerke - `nfs-common` (Debian/Ubuntu) bzw. `nfs-utils` (Fedora/Arch) für NFS-Laufwerke -- Für MAC-basierte lokale Laufwerke im **Nutzerkontext**: `mac2ip` löst über `ip neigh` auf, - ohne dafür Root-Rechte zu benötigen - nur der letzte Fallback-Schritt (ein aktiver - `nmap`-Scan, falls das Zielgerät nicht in der ARP-Nachbartabelle steht) braucht - 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. +- `systemd`: smart-mount richtet sich beim Installieren des Pakets automatisch als + System-Dienst ein (siehe "Automatischer Start beim Systemstart" unten). Ohne systemd bleibt + `smart-mount service crontab` als manueller Fallback. --- ## CLI-Nutzung +smart-mount läuft ausschließlich als root/System-Dienst - es gibt nur eine einzige, +systemweite Konfiguration. `drive add`/`edit`/`remove` sowie `mount`/`unmount`/`watch` +verlangen daher explizit Root-Rechte (`sudo`), auch für ein Laufwerkspaar, das nur für einen +bestimmten Nutzer gedacht ist (siehe `owner_user` unten). Rein lesende Befehle (`drive list`, +`status`, `doctor`) brauchen kein `sudo`. + ```bash -# Neues Laufwerkspaar interaktiv anlegen (fragt Name, Kontext, Mount-Typ, Adresse, -# Freigabe und ggf. Zugangsdaten ab) -smart-mount drive add +# Neues Laufwerkspaar interaktiv anlegen (fragt Name, Mount-Typ, Adresse, Freigabe und ggf. +# Zugangsdaten ab) +sudo smart-mount drive add # Nicht-interaktiv per Flags (für Skripte/Automatisierung) - fehlende Pflichtfelder sind # dann ein Fehler statt eines (ohne Terminal ohnehin unmöglichen) Prompts. Jedes # *-password-stdin-Flag liest genau eine Zeile von stdin (bei mehreren im selben Aufruf # entsprechend mehrere Zeilen, eine pro Flag in Reihenfolge): -smart-mount drive add --non-interactive \ - --name NAS --context system \ +sudo smart-mount drive add --non-interactive \ + --name NAS \ --local-kind smb --local-ip 192.168.1.50 --local-share share --local-username nasuser --local-password-stdin \ --cloud-kind webdav --cloud-host https://cloud.example.com/dav --cloud-share / --cloud-username clouduser --cloud-password cloud-pw-direkt \ <<< "lokales-passwort" @@ -55,35 +58,33 @@ smart-mount drive add --non-interactive \ # Bestehendes Laufwerkspaar bearbeiten - nur angegebene Felder ändern sich, alles andere # (inkl. gespeichertem Passwort) bleibt unangetastet. Ohne Flags: interaktiver Durchlauf, # vorbelegt mit den aktuellen Werten (inkl. Rückfrage, ob das Passwort geändert werden soll). -smart-mount drive edit --name "Neuer Name" --local-ip 192.168.1.99 -smart-mount drive edit --non-interactive --local-password-stdin <<< "neues-passwort" +sudo smart-mount drive edit --name "Neuer Name" --local-ip 192.168.1.99 +sudo smart-mount drive edit --non-interactive --local-password-stdin <<< "neues-passwort" # Konfigurierte Laufwerkspaare auflisten / entfernen (--json für Skripte) smart-mount drive list smart-mount drive list --json -smart-mount drive remove +sudo smart-mount drive remove # Ein einzelnes Paar oder alle einbinden/aushängen -smart-mount mount --name -smart-mount mount --all -smart-mount unmount --all +sudo smart-mount mount --name +sudo smart-mount mount --all +sudo smart-mount unmount --all # Aktuellen Status (Mount-Zustand + Erreichbarkeit beider Seiten) anzeigen (--json für Skripte) smart-mount status smart-mount status --json # Ein Reconcile-Durchlauf (lokal/Cloud-Umschaltung) - für systemd-Timer/Cron gedacht -smart-mount watch +sudo smart-mount watch -# systemd-Units installieren (System- bzw. Nutzerkontext) -sudo smart-mount service install --system -smart-mount service install --user +# Cron-Fallback für Systeme ohne (genutzten) systemd - siehe "Automatischer Start beim +# Systemstart" unten +sudo smart-mount service crontab +sudo smart-mount service crontab --remove -# Einmaliges root-Setup, damit Nutzer-Kontext-Paare unprivilegiert (un)gemountet werden können -sudo smart-mount setup fstab - -# Voraussetzungen prüfen (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) - deckt -# gebündelt ab, was man sonst erst einzeln beim Mount-Fehlschlag entdecken würde +# Voraussetzungen prüfen (Binaries, Scheduler) - deckt gebündelt ab, was man sonst erst +# einzeln beim Mount-Fehlschlag entdecken würde smart-mount doctor smart-mount doctor --json @@ -92,27 +93,22 @@ smart-mount completions bash > /etc/bash_completion.d/smart-mount smart-mount completions zsh > "${fpath[1]}/_smart-mount" ``` -Die Konfiguration liegt unter `~/.config/smart-mount/config.toml` (Nutzerkontext) bzw. -`/etc/smart-mount/config.toml` (root/System-Kontext) - automatisch aufgelöst je nachdem, ob -`smart-mount` mit Root-Rechten läuft. Die verschlüsselte Zugangsdaten-Datenbank -(`smart-mount.db`) liegt im selben Verzeichnis. +Die Konfiguration liegt immer unter `/etc/smart-mount/config.toml`, unabhängig davon, ob +`smart-mount` selbst mit oder ohne Root-Rechte aufgerufen wird (siehe oben). Die verschlüsselte +Zugangsdaten-Datenbank (`smart-mount.db`) liegt im selben Verzeichnis. ### Wo die Laufwerke eingebunden werden Jedes Laufwerkspaar bekommt sein **eigenes** Unterverzeichnis unter `settings.mount_base_dir` -(`/`) - mehrere Paare stören sich also nie gegenseitig. Standardwert -für `mount_base_dir`: `/run/media/smart-mount` im System-Kontext (root, ein gemeinsamer, -keinem Nutzer zugeordneter Namensraum), `/run/media//smart-mount` im Nutzerkontext - -`/run/media` ist auf den meisten Systemen bereits die übliche Konvention für eingebundene -Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, muss also nie -persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden bei jedem -`mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen - eigenes Anlegen ist nicht nötig. - -**Achtung bei Nutzer-Kontext-Paaren:** `/run/media` gehört standardmäßig `root:root` mit Modus -`0755` - ein normaler Nutzer kann dort also nicht einmal sein eigenes Unterverzeichnis -anlegen. `sudo smart-mount setup fstab` übernimmt das einmalig (legt `/run/media/` -sowie `/run/media//smart-mount` an und macht den Nutzer zum Besitzer) - ohne diesen -Schritt schlägt das automatische Anlegen für Nutzer-Kontext-Paare fehl. +(`/`) - mehrere Paare stören sich also nie gegenseitig. Standardwert: +`/run/media/smart-mount`, ein einzelner, flacher Namensraum (smart-mount mountet immer als +root, siehe oben) - `/run/media` ist auf den meisten Systemen bereits die übliche Konvention +für eingebundene Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) und liegt auf `tmpfs`, +muss also nie persistieren. Der Mountpoint selbst (und alle nötigen Elternverzeichnisse) werden +bei jedem `mount`/`watch`-Lauf automatisch angelegt, falls sie fehlen. Ist für ein Paar +`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 +über Zugriffsrechte, nicht über getrennte Mountpoint-Namensräume pro Nutzer. Der Standard lässt sich in `config.toml` unter `[settings] mount_base_dir = "..."` jederzeit auf einen beliebigen anderen Pfad ändern. @@ -121,45 +117,50 @@ auf einen beliebigen anderen Pfad ändern. ## Automatischer Start beim Systemstart +Das .deb/.rpm/.pkg.tar.zst-Paket richtet smart-mount beim Installieren **automatisch** als +System-systemd-Dienst ein - kein manueller Schritt nötig. Die dafür paketierten Unit-Dateien +(`packaging/systemd/` im Quellbaum, installiert nach `/usr/lib/systemd/system/`) bestehen aus: + +- `smart-mount-mount.service`: ein `oneshot`-Service, der beim Boot `smart-mount mount --all` + aufruft (nach `network-online.target`). +- `smart-mount-watch.service` + `smart-mount-watch.timer`: ein Timer, der periodisch + `smart-mount watch` aufruft (Standardintervall: 120s). + +Die postinst/postrm-Skripte des Pakets (bzw. das `.INSTALL`-Skriptlet bei Arch) aktivieren und +starten beide Units beim Installieren/Upgraden (`systemctl enable --now ...`) und deaktivieren +sie wieder beim Deinstallieren (`systemctl disable --now ...`) - siehe `packaging/deb/postinst` ++ `packaging/deb/postrm` bzw. `[package.metadata.generate-rpm]`/`scripts/package-arch.py` in +`Cargo.toml`. Welche Laufwerkspaare dabei gemountet werden (und für welchen Nutzer, siehe +`owner_user` unten) steuert ausschließlich `/etc/smart-mount/config.toml` - nicht die +Unit-Dateien selbst. + +**Anderes Watch-Intervall als der Standard (120s):** `settings.watch_interval_secs` in +`config.toml` steuert nur den Cron-Fallback (s. u.) - der paketierte Timer hat ein fest +eingebautes Intervall. Zum Anpassen: + ```bash -sudo smart-mount setup fstab # einmalig, nur nötig bei Nutzer-Kontext-Paaren -sudo smart-mount service install --system # für System-Kontext-Paare -smart-mount service install --user # für die eigenen Nutzer-Kontext-Paare +sudo systemctl edit smart-mount-watch.timer +# [Timer] +# OnUnitActiveSec=60s ``` -`service install` wählt automatisch den passenden Mechanismus: ist `systemctl` vorhanden, -werden systemd-Units installiert (ein `oneshot`-Service für den initialen Mount beim Boot -sowie ein Timer, der periodisch `smart-mount watch` aufruft - Intervall: -`settings.watch_interval_secs`, Standard 120s). Ist kein systemd vorhanden, wird automatisch -auf Cron ausgewichen - als root wird `/etc/cron.d/smart-mount` geschrieben, als normaler -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 -stattdessen die beiden äquivalenten Zeilen zum manuellen Eintragen ausgegeben: +### Cron-Fallback (Systeme ohne systemd) + +```bash +sudo smart-mount service crontab # einrichten +sudo smart-mount service crontab --remove # wieder entfernen +``` + +Erfordert Root (wie `mount`/`watch` - Mounten läuft immer als root, siehe oben) und schreibt +`/etc/cron.d/smart-mount` - das Intervall folgt `settings.watch_interval_secs`. Ist +`/etc/cron.d` nicht vorhanden, werden stattdessen die beiden äquivalenten Zeilen zum manuellen +Eintragen ausgegeben: ```cron @reboot smart-mount mount --all */2 * * * * smart-mount watch ``` -`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 @@ -189,39 +190,20 @@ Architekturentscheidungen unten). beim Umschalten zuerst die neue Seite gemountet und der Symlink umgebogen wird, bevor die alte Seite (mit eben diesem tolerierten Timeout) ausgehängt wird, blockiert ein hängender Alt-Mount den sichtbaren Wechsel ohnehin nicht. -- **Privilegienmodell für Nutzer-Mounts**: `sudo smart-mount setup fstab` schreibt einmalig - `/etc/fstab`-Einträge mit `user,exec,noauto` sowie nötige Gruppenmitgliedschaften (z. B. - `davfs2`-Gruppe). Jede Seite (lokal/Cloud) bekommt dabei ihr **eigenes, eindeutiges** - verstecktes Backing-Verzeichnis (nicht denselben Mountpoint für beide) - das entspricht - exakt dem einzigen in `man 8 mount` ("Non-superuser mounts") dokumentierten Fall, statt sich - auf unspezifiziertes Verhalten bei zwei Zeilen mit demselben Ziel zu verlassen. Der - konfigurierte, sichtbare Mountpoint selbst ist ein Symlink, den SmartMount zur Laufzeit - zwischen den beiden Backing-Verzeichnissen umschaltet. `exec` wird explizit gesetzt, weil die - `user`-Option laut `man 8 mount` sonst für jedes Dateisystem automatisch `noexec` erzwingt - - ohne das könnten auf einem Nutzer-Kontext-Laufwerk liegende Skripte nicht ausgeführt werden. +- **Privilegienmodell**: SmartMount läuft ausschließlich als root/System-Dienst - es gibt + keinen unprivilegierten Mount-Weg mehr (früher: `/etc/fstab`-Einträge mit `user,noauto`, über + die ein einzelner Nutzer unprivilegiert selbst mounten konnte). Root mountet direkt + (`mount -t -o `) für jedes konfigurierte Paar, unabhängig + davon, ob/für welchen Nutzer `owner_user` gesetzt ist - siehe `owner_user` unten für die + Zugriffssteuerung. Das vereinfacht das Rechtemodell erheblich und schließt nebenbei die + frühere strukturelle CIFS-Lücke (jeder unprivilegiert mount-berechtigte Nutzer konnte zuvor + jedes CIFS-Nutzer-Kontext-Paar mounten, nicht nur sein eigenes) - Zugangsdaten sind jetzt nur + noch root zugänglich. - **Voller Zugriff für einen bestimmten Nutzer (`owner_user`)**: Bei CIFS/WebDAV (keine nativen Unix-Rechte) setzt SmartMount automatisch `uid=`/`gid=`/`file_mode=0700`/ - `dir_mode=0700`, sobald ein Paar ein `owner_user` hat (Pflicht bei Nutzer-Kontext-Paaren, - optional bei System-Kontext). Das ist nicht nur für vollen Zugriff (inkl. Skript-Ausführung) - nötig, sondern bei WebDAV auch sicherheitsrelevant: `mount.davfs` erlaubt einem - 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. + `dir_mode=0700`, sobald ein Paar ein `owner_user` hat - optional, ohne `owner_user` gehört + der Mount root. 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. - **davfs2-Konfiguration**: SmartMount setzt in `davfs2.conf` automatisch `gui_optimize 1` (bündelt PROPFIND-Anfragen, wichtig für grafische Dateimanager) sowie `buf_size 16384` (deutlich über dem Standard von 16 KiB) - Letzteres behebt ein bekanntes Praxisproblem, bei @@ -230,10 +212,9 @@ Architekturentscheidungen unten). kleinem `buf_size` stillschweigend abgeschnitten). - **Zugangsdaten-Verschlüsselung**: Turso hat aktuell keine produktionsreife eingebaute Verschlüsselung, daher verschlüsselt SmartMount Passwörter selbst (AES-256-GCM) vor der - Ablage. Der Master-Schlüssel wird bevorzugt im OS-Keyring (GNOME Keyring/KWallet über - secret-service) abgelegt; ist keins verfügbar (typisch für den root/System-Dienst sowie - Headless-Systeme), wird automatisch auf eine Schlüsseldatei (`chmod 600`, neben der - Konfiguration) zurückgegriffen. + Ablage. Der Master-Schlüssel liegt in einer Schlüsseldatei (`chmod 600`, neben der + Konfiguration, also `/etc/smart-mount/master.key`) - kein OS-Keyring, da SmartMount + ausschließlich als root/System-Dienst läuft, für den es kein Nutzer-Keyring gibt. - **`mac2ip`-Integration**: SmartMount ruft `mac2ip --json --auto-trust-networks ` auf. `--auto-trust-networks` lässt mac2ip die eigene "nmap-Scan in diesem Netzwerk erlauben?"- Rückfrage automatisch bejahen und dauerhaft in seiner eigenen Cache-Datenbank merken - das @@ -249,6 +230,10 @@ Architekturentscheidungen unten). │ └── config.toml # Cargo-Konfiguration (Linker für Cross-Compiling, Registry) ├── .gitea/ │ └── workflows/ # CI/CD-Pipelines (Build, Tests, Security-Scans, Releases) +├── packaging/ +│ ├── systemd/ # Statische systemd-Unit-Dateien (paketiert nach +│ │ # /usr/lib/systemd/system/) +│ └── deb/ # postinst/postrm für das .deb-Paket ├── scripts/ │ ├── get-build-number.py # Dynamische Ermittlung der nächsten Paket-Revisionsnummer │ ├── package-arch.py # Erstellung von Arch Linux .pkg.tar.zst Paketen @@ -263,8 +248,7 @@ Architekturentscheidungen unten). │ ├── mount/ # WebDAV/SMB/NFS-Backends, dynamischer Dispatch │ ├── network/ # Erreichbarkeit, mac2ip-Integration │ ├── reconcile/ # Watchdog-Entscheidungslogik (lokal/Cloud-Umschaltung) -│ ├── systemd/ # systemd-Unit-Generierung/-Installation -│ └── fstab/ # Einmaliges root-Setup für Nutzer-Mounts +│ └── systemd/ # Cron-Fallback (Systeme ohne systemd) ├── tests/ # Cross-Modul-Integrationstests ├── Cargo.toml # Cargo Manifest & Paketierungsmetadaten (deb, rpm, arch) ├── LICENSE # Lizenzdatei (GPL-3.0-or-later) diff --git a/packaging/deb/postinst b/packaging/deb/postinst new file mode 100755 index 0000000..a9cd8ea --- /dev/null +++ b/packaging/deb/postinst @@ -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 diff --git a/packaging/deb/postrm b/packaging/deb/postrm new file mode 100755 index 0000000..b6e577b --- /dev/null +++ b/packaging/deb/postrm @@ -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 diff --git a/packaging/systemd/smart-mount-mount.service b/packaging/systemd/smart-mount-mount.service new file mode 100644 index 0000000..9027017 --- /dev/null +++ b/packaging/systemd/smart-mount-mount.service @@ -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 diff --git a/packaging/systemd/smart-mount-watch.service b/packaging/systemd/smart-mount-watch.service new file mode 100644 index 0000000..144b687 --- /dev/null +++ b/packaging/systemd/smart-mount-watch.service @@ -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 diff --git a/packaging/systemd/smart-mount-watch.timer b/packaging/systemd/smart-mount-watch.timer new file mode 100644 index 0000000..5bedd4f --- /dev/null +++ b/packaging/systemd/smart-mount-watch.timer @@ -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 diff --git a/scripts/package-arch.py b/scripts/package-arch.py index d03aeee..5527523 100755 --- a/scripts/package-arch.py +++ b/scripts/package-arch.py @@ -45,6 +45,52 @@ def resolve_pkgrel(version=None, default="1"): return str(default) +def collect_systemd_units(systemd_src_dir="packaging/systemd"): + """Findet die im Projekt paketierten systemd-Unit-Dateien (falls vorhanden) und + unterscheidet dabei generisch - ohne einen Anwendungsnamen zu kennen - zwischen direkt + aktivierbaren Units (die einen '[Install]'-Abschnitt haben, z. B. Timer oder ein beim Boot + laufender oneshot-Service) und rein abhängigen Units (z. B. ein nur von einem Timer + ausgelöster Service ohne eigenen '[Install]'-Abschnitt, siehe `man systemd.unit`).""" + if not os.path.isdir(systemd_src_dir): + return [], [] + + unit_files = sorted( + f for f in os.listdir(systemd_src_dir) if f.endswith((".service", ".timer", ".socket")) + ) + installable = [] + for f in unit_files: + with open(os.path.join(systemd_src_dir, f)) as fh: + if "[Install]" in fh.read(): + installable.append(f) + return unit_files, installable + + +def build_install_scriptlet(installable, all_units): + """Erzeugt den Inhalt einer Arch-'.INSTALL'-Datei (siehe `man PKGBUILD`, Abschnitt + 'install'), die den paketierten systemd-Dienst beim Installieren aktiviert/startet und beim + Entfernen wieder deaktiviert/stoppt - rein generisch anhand der tatsächlich gefundenen + Unit-Dateien, ohne Anwendungsnamen hart zu codieren.""" + installable_str = " ".join(installable) + all_units_str = " ".join(all_units) + return f"""post_install() {{ + systemctl daemon-reload >/dev/null 2>&1 || true + systemctl enable --now {installable_str} >/dev/null 2>&1 || true +}} + +post_upgrade() {{ + systemctl daemon-reload >/dev/null 2>&1 || true +}} + +pre_remove() {{ + systemctl disable --now {all_units_str} >/dev/null 2>&1 || true +}} + +post_remove() {{ + systemctl daemon-reload >/dev/null 2>&1 || true +}} +""" + + def build_package(target_triple=None, target_arch=None, pkgrel=None): metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--format-version", "1", "--no-deps"])) pkg = metadata["packages"][0] @@ -102,6 +148,17 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None): if os.path.exists("README.md"): subprocess.run(["install", "-m", "644", "README.md", f"{doc_dir}/README.md"], check=True) + systemd_src_dir = "packaging/systemd" + all_units, installable_units = collect_systemd_units(systemd_src_dir) + if all_units: + unit_dir = os.path.join(build_dir, "usr/lib/systemd/system") + os.makedirs(unit_dir, exist_ok=True) + for unit in all_units: + subprocess.run( + ["install", "-m", "644", os.path.join(systemd_src_dir, unit), os.path.join(unit_dir, unit)], + check=True, + ) + installed_size = subprocess.check_output(["du", "-sb", build_dir]).decode().split()[0] builddate = str(int(time.time())) @@ -121,14 +178,23 @@ def build_package(target_triple=None, target_arch=None, pkgrel=None): pkginfo_lines.append(f"depend = {dep}") for optdep in optdepends: pkginfo_lines.append(f"optdepend = {optdep}") + if all_units: + pkginfo_lines.append(f"install = {name}.install") pkginfo_lines.append("makepkgopt = strip\n") with open(os.path.join(build_dir, ".PKGINFO"), "w") as f: f.write("\n".join(pkginfo_lines)) + tar_members = [".PKGINFO", "usr"] + if all_units: + install_script_name = f"{name}.install" + with open(os.path.join(build_dir, install_script_name), "w") as f: + f.write(build_install_scriptlet(installable_units, all_units)) + tar_members.append(install_script_name) + os.makedirs("target/arch", exist_ok=True) output_file = os.path.abspath(f"target/arch/{name}-{version}-{pkgrel}-{arch}.pkg.tar.zst") - subprocess.run(["tar", "--zstd", "-cf", output_file, ".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}") diff --git a/src/cli/drive.rs b/src/cli/drive.rs index 35a08af..c07f902 100644 --- a/src/cli/drive.rs +++ b/src/cli/drive.rs @@ -12,7 +12,7 @@ use clap::{Args, Subcommand}; use dialoguer::{Confirm, Input, Password, Select}; 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}; @@ -40,8 +40,6 @@ pub enum DriveAction { pub struct DriveArgs { #[arg(long)] name: Option, - #[arg(long, value_enum)] - context: Option, #[arg(long)] owner_user: Option, @@ -108,10 +106,9 @@ async fn list(json: bool) -> anyhow::Result<()> { } for pair in &cfg.pairs { println!( - "{} \"{}\" [{:?}] lokal={} cloud={} -> {}", + "{} \"{}\" lokal={} cloud={} -> {}", pair.id, pair.name, - pair.context, pair.local.kind.as_str(), pair.cloud.kind.as_str(), pair.mount_point.display() @@ -121,6 +118,8 @@ async fn list(json: bool) -> 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 // Mount-Typen/Adressen betroffen sind - nötig, um die passenden Klartext-Zugangsdaten // (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<()> { + crate::cli::require_root("drive add")?; let ni = args.non_interactive; let name = resolve_field(args.name, None, "Drive pair name", ni, true)?.expect("required"); - let context = resolve_context(args.context, None, ni)?; - let owner_user = resolve_owner_user(args.owner_user, context, None, ni)?; + let owner_user = resolve_owner_user(args.owner_user, None, 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)?; @@ -199,7 +198,6 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { id: id.clone(), name, enabled: true, - context, owner_user, mount_point, local: LocalSide { @@ -233,22 +231,18 @@ async fn add(args: DriveArgs) -> anyhow::Result<()> { } 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(()) } async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { + crate::cli::require_root("drive edit")?; let cfg = config::pairs::load()?; let existing = config::pairs::find_pair(&cfg, id)?; let ni = args.non_interactive; let name = resolve_field(args.name, Some(&existing.name), "Drive pair name", ni, true)? .expect("required"); - let context = resolve_context(args.context, Some(existing.context), ni)?; - let owner_user = - resolve_owner_user(args.owner_user, context, existing.owner_user.as_deref(), ni)?; + let owner_user = resolve_owner_user(args.owner_user, existing.owner_user.as_deref(), ni)?; let local_kind = resolve_kind( args.local_kind, @@ -320,10 +314,9 @@ async fn edit(id: &str, args: DriveArgs) -> anyhow::Result<()> { id: id.to_string(), name, enabled: existing.enabled, - context, owner_user, // 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(), local: LocalSide { kind: local_kind, @@ -398,89 +391,32 @@ fn resolve_field( Ok(Some(input.interact_text()?)) } -fn resolve_context( - flag: Option, - current: Option, - non_interactive: bool, -) -> anyhow::Result { - 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 - }) -} - +/// Optional: falls gesetzt, bekommt dieser Nutzer bei CIFS/WebDAV vollen Zugriff +/// (uid=/gid=/file_mode=0700/dir_mode=0700) statt der sonst üblichen root-Ownership - der +/// Mount selbst läuft immer als root (siehe [`smart_mount::systemd`]). fn resolve_owner_user( flag: Option, - context: MountContext, current: Option<&str>, non_interactive: bool, ) -> anyhow::Result> { if let Some(v) = flag { return Ok(Some(v)); } - match context { - MountContext::User => { - // 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::::new().with_prompt("Linux username"); - if let Some(c) = current { - input = input.default(c.to_string()); - } - Ok(Some(input.interact_text()?)) - } + 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::::new().with_prompt("Linux username"); + if let Some(c) = current { + input = input.default(c.to_string()); + } + Ok(Some(input.interact_text()?)) } fn resolve_kind( diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 73ca3dd..f7ba31d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,13 +4,23 @@ pub mod doctor; pub mod drive; pub mod mount_cmd; pub mod service; -pub mod setup; pub mod status; pub mod watch; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::Shell; +/// Bricht mit einer klaren Fehlermeldung ab, falls nicht als root aufgerufen. Mounten läuft +/// ausschließlich als root/System-Dienst (siehe [`smart_mount::systemd`]) - es gibt seit dem +/// Wegfall des Nutzerkontexts keine unprivilegierte Mount-Variante mehr, für die ein +/// Rechte-Check hier zu früh käme. +pub(crate) fn require_root(context: &str) -> anyhow::Result<()> { + if !sudo_ctdra::is_run_as_root() { + anyhow::bail!("'{context}' requires root privileges (re-run with sudo)."); + } + Ok(()) +} + #[derive(Parser)] #[command( name = "smart-mount", @@ -55,17 +65,14 @@ pub enum Commands { }, /// A single reconcile pass (local/cloud switching) - meant for systemd timers/cron. 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 { #[command(subcommand)] action: service::ServiceAction, }, - /// One-time root setup for unprivileged user mounts. - Setup { - #[command(subcommand)] - action: setup::SetupAction, - }, - /// Checks prerequisites (binaries, group membership, fstab setup, scheduler). + /// Checks prerequisites (binaries, scheduler). Doctor { /// Output as JSON instead of text - for scripts. #[arg(long)] @@ -85,7 +92,6 @@ pub async fn dispatch(cli: Cli) -> anyhow::Result<()> { Commands::Status { name, json } => status::run(name, json).await, Commands::Watch => watch::run().await, Commands::Service { action } => service::run(action), - Commands::Setup { action } => setup::run(action), Commands::Doctor { json } => doctor::run(json).await, Commands::Completions { shell } => { clap_complete::generate( diff --git a/src/cli/mount_cmd.rs b/src/cli/mount_cmd.rs index 986baaa..6fad7d5 100644 --- a/src/cli/mount_cmd.rs +++ b/src/cli/mount_cmd.rs @@ -1,6 +1,6 @@ //! `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::reconcile; @@ -15,27 +15,11 @@ fn select_pairs( if !all { anyhow::bail!("Please specify '--name ' or '--all'."); } - - 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) + Ok(cfg.pairs.clone()) } pub async fn run_mount(name: Option, all: bool) -> anyhow::Result<()> { + crate::cli::require_root("mount")?; let cfg = config::pairs::load()?; let pairs = select_pairs(&cfg, name.as_deref(), all)?; if pairs.is_empty() { @@ -52,6 +36,7 @@ pub async fn run_mount(name: Option, all: bool) -> anyhow::Result<()> { } pub async fn run_unmount(name: Option, all: bool) -> anyhow::Result<()> { + crate::cli::require_root("unmount")?; let cfg = config::pairs::load()?; let pairs = select_pairs(&cfg, name.as_deref(), all)?; if pairs.is_empty() { diff --git a/src/cli/service.rs b/src/cli/service.rs index 158b05e..9539da5 100644 --- a/src/cli/service.rs +++ b/src/cli/service.rs @@ -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::fstab; -use smart_mount::systemd::{self, Scope}; +use smart_mount::systemd; #[derive(Subcommand)] pub enum ServiceAction { - /// Sets up periodic execution: systemd if available, otherwise falls back to cron - /// automatically (see `crontab`). - Install(ScopeArgs), - /// Removes everything that `install`/`crontab`/`setup fstab` have set up - systemd - /// units, cron entry, and (only for `--system`) the managed `/etc/fstab` block. - /// Missing parts are skipped, not treated as an error. - Uninstall(ScopeArgs), - /// Sets up periodic execution via cron (alternative to `install` for systems without - /// 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 { - 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'."), - } - } + /// Sets up (or, with '--remove', tears down) periodic execution via + /// '/etc/cron.d/smart-mount' - a manual fallback for systems that don't use the packaged + /// systemd service (see 'packaging/systemd/' in the source tree). Requires root, same as + /// 'mount'/'watch' - mounting always runs as root. + Crontab { + /// Removes a previously installed cron entry instead of installing one. + #[arg(long)] + remove: bool, + }, } pub fn run(action: ServiceAction) -> anyhow::Result<()> { + crate::cli::require_root("service crontab")?; match action { - ServiceAction::Install(args) => { - let scope = args.scope()?; - 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(()) - } + ServiceAction::Crontab { remove: false } => install(), + ServiceAction::Crontab { remove: true } => uninstall(), } } + +fn install() -> anyhow::Result<()> { + let cfg = config::pairs::load()?; + let interval = cfg.settings.watch_interval_secs; + + match systemd::install_cron(interval)? { + systemd::CronInstallOutcome::SystemFile(path) => { + println!("Cron entry written: {}", path.display()); + } + systemd::CronInstallOutcome::Unavailable => { + println!( + "No cron mechanism found ('/etc/cron.d' is missing) - here are the lines for manual entry:" + ); + print!("{}", systemd::crontab_equivalent(interval)); + } + } + Ok(()) +} + +fn uninstall() -> anyhow::Result<()> { + match systemd::uninstall_cron()? { + systemd::CronUninstallOutcome::Removed => println!("Cron entry removed."), + systemd::CronUninstallOutcome::NotPresent => { + println!("Nothing to remove - no cron entry was installed.") + } + } + Ok(()) +} diff --git a/src/cli/setup.rs b/src/cli/setup.rs deleted file mode 100644 index 4a47bdb..0000000 --- a/src/cli/setup.rs +++ /dev/null @@ -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(()) - } - } -} diff --git a/src/cli/watch.rs b/src/cli/watch.rs index 39dea80..df4e677 100644 --- a/src/cli/watch.rs +++ b/src/cli/watch.rs @@ -7,6 +7,7 @@ use smart_mount::reconcile::{self, Action}; use crate::cli::mount_cmd::print_outcome; pub async fn run() -> anyhow::Result<()> { + crate::cli::require_root("watch")?; let cfg = config::pairs::load()?; let creds = CredentialStore::open().await?; let outcomes = reconcile::watch_once(&cfg, &creds).await; diff --git a/src/config/mod.rs b/src/config/mod.rs index 692ecfd..cbb053e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -4,12 +4,20 @@ pub mod pairs; pub mod schema; pub use schema::{ - AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountContext, - MountKind, + AppConfig, CloudSide, DrivePair, GlobalSettings, LocalAddress, LocalSide, MountKind, }; -/// Initialisiert den Konfigurationsdateinamen bei `config-ctdra`. Muss vor dem ersten -/// `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter Zustand). +/// Initialisiert `config-ctdra`: Dateiname und - unabhängig von den Rechten des aufrufenden +/// Prozesses - immer der System-Pfad (`/etc//config.toml`). Es gibt nur noch +/// eine einzige, systemweite Konfiguration (smart-mount läuft ausschließlich als root/ +/// System-Dienst, siehe [`crate::systemd`]) statt einer je nach Aufrufer wechselnden +/// Root-/Nutzerpfad-Auflösung - `drive add`/`edit`/`remove` verlangen deshalb explizit Root +/// (siehe `cli::require_root`), auch wenn sie nicht direkt mounten. +/// +/// Muss vor dem ersten `load`/`store`/`get_config`-Aufruf laufen (globaler, prozessweiter +/// Zustand). pub fn init() { config_ctdra::set_config_name("config"); + let program = config_ctdra::get_program_name(); + config_ctdra::set_custom_dir(std::path::PathBuf::from("/etc").join(program)); } diff --git a/src/config/schema.rs b/src/config/schema.rs index cfb536d..5bccafd 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -11,39 +11,13 @@ use serde::{Deserialize, Serialize}; /// `/run/media` ist die auf diesem System bereits übliche Konvention für eingebundene /// Wechseldatenträger/Netzlaufwerke (z. B. udisks2/GNOME) - tmpfs-hinterlegt, wird also bei /// jedem Boot ohnehin leer neu angelegt, passend dazu, dass Mountpoints selbst nie -/// persistieren müssen. Root-/System-Kontext-Paare landen flach unter `/run/media/smart-mount` -/// (ein systemweiter Dienst, keinem einzelnen Nutzer zugeordnet); Nutzer-Kontext-Paare unter -/// `/run/media//smart-mount`, damit mehrere lokale Nutzer mit eigenen Paaren sich -/// nicht denselben Namensraum teilen. +/// persistieren müssen. Ein einzelner, flacher Namensraum reicht: smart-mount läuft +/// ausschließlich als root/System-Dienst (siehe [`crate::systemd`]) und mountet dort auch +/// Paare mit gesetztem `owner_user` - die Trennung nach Linux-Nutzer passiert über die +/// `uid=`/`gid=`-Mount-Optionen (siehe [`crate::mount::target::build_target`]), nicht über +/// unterschiedliche Mountpoint-Namensräume. fn default_mount_base_dir() -> PathBuf { - if sudo_ctdra::is_run_as_root() { - 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") - } + PathBuf::from("/run/media/smart-mount") } fn default_log_level() -> String { @@ -58,9 +32,8 @@ fn default_mac2ip_binary() -> String { "mac2ip".to_string() } -/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` unter -/// `~/.config/smart-mount/config.toml` (Nutzerkontext) bzw. `/etc/smart-mount/config.toml` -/// (Root-Kontext). +/// Wurzel-Konfigurationsstruktur, gespeichert via `config-ctdra` immer unter +/// `/etc/smart-mount/config.toml` (siehe [`crate::config::init`]). #[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct AppConfig { #[serde(default)] @@ -76,8 +49,10 @@ pub struct GlobalSettings { pub mount_base_dir: PathBuf, #[serde(default = "default_log_level")] pub log_level: String, - /// Periode, mit der `smart-mount watch` über den generierten systemd-Timer bzw. die - /// dokumentierte Crontab-Zeile ausgeführt werden soll. + /// Periode, mit der `smart-mount watch` über den Cron-Fallback (`smart-mount service + /// crontab`, siehe [`crate::systemd`]) ausgeführt werden soll. Der paketierte systemd-Timer + /// hat ein fest eingebautes Intervall (siehe `packaging/systemd/smart-mount-watch.timer`) + /// und liest dieses Feld nicht. #[serde(default = "default_watch_interval_secs")] pub watch_interval_secs: u64, /// Name/Pfad des `mac2ip`-Binaries (per PATH auflösbar, oder absoluter Pfad). @@ -101,14 +76,14 @@ impl Default for GlobalSettings { /// eingebunden - siehe [`crate::reconcile`]. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct DrivePair { - /// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten, Mount-Unterverzeichnis, - /// systemd-Unit-Namen und fstab-Einträge. + /// Stabile ID (uuid-v4), Schlüssel für DB-Zugangsdaten sowie das Mount-Unterverzeichnis. pub id: String, pub name: String, #[serde(default = "default_true")] pub enabled: bool, - pub context: MountContext, - /// Pflicht bei `context == User`: der Linux-Benutzername, dem dieses Paar gehört. + /// Optional: der Linux-Benutzername, der vollen Zugriff (uid/gid) auf dieses Paar + /// bekommen soll (siehe [`crate::mount::target::build_target`]). Ohne `owner_user` gehört + /// der Mount root. #[serde(default)] pub owner_user: Option, pub mount_point: PathBuf, @@ -120,16 +95,6 @@ fn default_true() -> bool { 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. #[derive(Serialize, Deserialize, Clone, Debug)] pub struct LocalSide { @@ -198,7 +163,6 @@ mod tests { id: "pair-1".into(), name: "NAS".into(), enabled: true, - context: MountContext::User, owner_user: Some("dragon".into()), mount_point: PathBuf::from("/home/dragon/smart-mount/pair-1"), local: LocalSide { @@ -223,7 +187,6 @@ mod tests { assert_eq!(round_tripped.pairs.len(), 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 { LocalAddress::Mac(mac) => assert_eq!(mac, "aa:bb:cc:dd:ee:ff"), LocalAddress::Ip(_) => panic!("expected Mac variant"), @@ -232,12 +195,9 @@ mod tests { #[test] fn default_mount_base_dir_uses_run_media() { - // Tests laufen nie als root, daher greift hier immer der Nutzerkontext-Zweig. - let dir = default_mount_base_dir(); - assert!(dir.starts_with("/run/media")); - assert!(dir.ends_with("smart-mount")); - if let Ok(user) = std::env::var("USER") { - assert!(dir.to_string_lossy().contains(&user)); - } + assert_eq!( + default_mount_base_dir(), + PathBuf::from("/run/media/smart-mount") + ); } } diff --git a/src/crypto/key.rs b/src/crypto/key.rs index 72a9c91..6b34aab 100644 --- a/src/crypto/key.rs +++ b/src/crypto/key.rs @@ -1,11 +1,8 @@ //! Master-Schlüssel-Auflösung für die Zugangsdaten-Verschlüsselung. //! -//! Reihenfolge (wie mit dem Nutzer abgestimmt): -//! - Root-/System-Kontext: **immer** die Schlüsseldatei (kein Nutzer-Keyring im -//! Systemdienst-Kontext verfügbar). -//! - 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. +//! Immer die Schlüsseldatei: SmartMount läuft ausschließlich als root/System-Dienst (siehe +//! [`crate::systemd`]), für den es kein Nutzer-Keyring (GNOME Keyring/KWallet über +//! secret-service) gibt. use std::fs::{self, File, OpenOptions}; use std::io::{Read, Write}; @@ -13,64 +10,13 @@ use std::path::{Path, PathBuf}; 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_BACKEND_MARKER_NAME: &str = "master.key.backend"; const KEY_LEN: usize = 32; /// 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. -/// -/// 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. +/// Zugangsdaten-Verschlüsselung. pub fn resolve_master_key() -> Result<[u8; 32]> { - if sudo_ctdra::is_run_as_root() { - 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() - ), - ); - } + file_key::load_or_create(&key_file_path()) } fn key_file_path() -> PathBuf { @@ -81,14 +27,6 @@ fn key_file_path() -> PathBuf { .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 { use super::*; @@ -150,61 +88,3 @@ mod file_key { .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) - } -} diff --git a/src/doctor.rs b/src/doctor.rs index a854ef0..8dff1f8 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -1,11 +1,10 @@ //! Diagnose-Checks für `smart-mount doctor` - prüft die im Laufe der Entwicklung -//! angesammelten Voraussetzungen (Binaries, Gruppenmitgliedschaft, fstab-Setup, Scheduler) -//! gebündelt an einer Stelle, statt sie einzeln erst beim Mount-Fehlschlag zu entdecken. +//! angesammelten Voraussetzungen (Binaries, Scheduler) gebündelt an einer Stelle, statt sie +//! einzeln erst beim Mount-Fehlschlag zu entdecken. 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; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -68,23 +67,6 @@ pub fn run_checks(cfg: &AppConfig) -> Vec { 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 } @@ -141,12 +123,12 @@ fn check_scheduler() -> CheckResult { if systemd { ok( "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 { warn( "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 { 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)] mod tests { use super::*; - use crate::config::{CloudSide, GlobalSettings, LocalSide}; + use crate::config::{CloudSide, DrivePair, GlobalSettings, LocalSide}; 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 { id: "pair-1".into(), name: "Test".into(), enabled: true, - context, owner_user: owner_user.map(str::to_string), mount_point: "/media/smart-mount/pair-1".into(), local: LocalSide { @@ -288,7 +198,7 @@ mod tests { fn used_mount_kinds_collects_both_sides_across_pairs() { let cfg = AppConfig { settings: GlobalSettings::default(), - pairs: vec![sample_pair(MountContext::System, None, false)], + pairs: vec![sample_pair(None, false)], }; let kinds = used_mount_kinds(&cfg); assert!(kinds.contains(&MountKind::Nfs)); @@ -300,40 +210,16 @@ mod tests { fn uses_mac_addressing_detects_mac_pairs() { let with_mac = AppConfig { settings: GlobalSettings::default(), - pairs: vec![sample_pair(MountContext::System, None, true)], + pairs: vec![sample_pair(None, true)], }; let without_mac = AppConfig { 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(&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] fn empty_config_still_runs_global_checks_without_panicking() { let cfg = AppConfig::default(); diff --git a/src/error.rs b/src/error.rs index fedbb2f..1d054f4 100644 --- a/src/error.rs +++ b/src/error.rs @@ -39,9 +39,6 @@ pub enum Error { #[error("mac2ip resolution failed for MAC {mac}: {reason}")] Mac2Ip { mac: String, reason: String }, - #[error("No root context: {0}")] - RequiresRoot(&'static str), - #[error("{0}")] Other(String), } diff --git a/src/fstab/mod.rs b/src/fstab/mod.rs deleted file mode 100644 index 47a67ab..0000000 --- a/src/fstab/mod.rs +++ /dev/null @@ -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 = match &sudo_user { - Some(u) => vec![u.clone()], - None => user_pairs - .iter() - .filter_map(|p| p.owner_user.clone()) - .collect::>() - .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 { - 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//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(¤t_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 { - 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 = 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 { - // 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 { - 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/` 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")); - } -} diff --git a/src/lib.rs b/src/lib.rs index 70a86c2..3757831 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,11 +6,9 @@ pub mod crypto; pub mod db; pub mod doctor; pub mod error; -pub mod fstab; pub mod mount; pub mod network; pub mod reconcile; pub mod systemd; -pub(crate) mod util; pub use error::{Error, Result}; diff --git a/src/mount/mod.rs b/src/mount/mod.rs index c045f82..96974b2 100644 --- a/src/mount/mod.rs +++ b/src/mount/mod.rs @@ -18,21 +18,9 @@ use crate::config::{DrivePair, GlobalSettings, MountKind}; use crate::db::credentials::{Credential, Side}; 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 -o `. - Direct, - /// Unprivilegiert über eine passende `user,noauto`-fstab-Zeile: `mount ` (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- -/// bzw. auszuhängen. +/// bzw. auszuhängen. Der Mount-Aufruf ist immer `mount -t -o ` +/// als root (smart-mount läuft ausschließlich als root/System-Dienst, siehe [`crate::systemd`]). pub struct MountTarget { pub pair_id: String, /// Welche Seite des Paars (lokal/cloud) dieses Ziel betrifft - bestimmt u. a. stabile @@ -41,12 +29,11 @@ pub struct MountTarget { pub mount_point: PathBuf, /// Vollständiger Quellstring, z. B. `//server/share`, `https://host/path`, `server:/export`. pub source: String, - /// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`), nur bei - /// `MountInvocation::Direct` verwendet - mit Kommas verbindbar für `mount -o`. + /// `-o`-Optionen als rohe Tokens (`"uid=1000"` oder bloße Flags wie `"soft"`) - mit Kommas + /// verbindbar für `mount -o`. pub options: Vec, - pub invocation: MountInvocation, - /// Für `MountContext::User`-Paare: der Linux-Benutzername, dem Zugangsdaten-/Secrets- - /// Dateien gehören sollen. + /// Für Paare mit gesetztem `owner_user`: der Linux-Benutzername, dem Zugangsdaten-/ + /// Secrets-Dateien gehören sollen. pub owner_user: Option, } diff --git a/src/mount/nfs.rs b/src/mount/nfs.rs index 1b07822..8263684 100644 --- a/src/mount/nfs.rs +++ b/src/mount/nfs.rs @@ -5,9 +5,7 @@ use std::process::Command; use crate::db::credentials::Credential; use crate::error::{Error, Result}; -use crate::mount::{ - MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done, -}; +use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done}; pub struct NfsBackend; @@ -34,19 +32,12 @@ impl MountBackend for NfsBackend { fn mount(&self, target: &MountTarget) -> Result<()> { let mut cmd = Command::new("mount"); - match target.invocation { - MountInvocation::Direct => { - cmd.arg("-t") - .arg("nfs") - .arg(&target.source) - .arg(&target.mount_point); - if !target.options.is_empty() { - cmd.arg("-o").arg(target.options.join(",")); - } - } - MountInvocation::ViaFstab => { - cmd.arg(&target.mount_point); - } + cmd.arg("-t") + .arg("nfs") + .arg(&target.source) + .arg(&target.mount_point); + if !target.options.is_empty() { + cmd.arg("-o").arg(target.options.join(",")); } run_tolerating_already_done(cmd, "nfs mount", false) } diff --git a/src/mount/smb.rs b/src/mount/smb.rs index 29789e9..848e286 100644 --- a/src/mount/smb.rs +++ b/src/mount/smb.rs @@ -7,9 +7,7 @@ use std::process::Command; use crate::db::credentials::Credential; use crate::error::{Error, Result}; -use crate::mount::{ - MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done, -}; +use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done}; pub struct SmbBackend; @@ -38,23 +36,16 @@ impl MountBackend for SmbBackend { fn mount(&self, target: &MountTarget) -> Result<()> { let mut cmd = Command::new("mount"); - match target.invocation { - MountInvocation::Direct => { - cmd.arg("-t") - .arg("cifs") - .arg(&target.source) - .arg(&target.mount_point); - let mut opts = target.options.clone(); - opts.push(format!( - "credentials={}", - credentials_path(&target.pair_id, target.side).display() - )); - cmd.arg("-o").arg(opts.join(",")); - } - MountInvocation::ViaFstab => { - cmd.arg(&target.mount_point); - } - } + cmd.arg("-t") + .arg("cifs") + .arg(&target.source) + .arg(&target.mount_point); + let mut opts = target.options.clone(); + opts.push(format!( + "credentials={}", + credentials_path(&target.pair_id, target.side).display() + )); + cmd.arg("-o").arg(opts.join(",")); run_tolerating_already_done(cmd, "cifs mount", false) } @@ -65,8 +56,8 @@ impl MountBackend for SmbBackend { } } -/// Stabiler Pfad (nicht ein Tempfile!), da bei `MountInvocation::ViaFstab` die fstab-Zeile -/// (von `setup fstab` einmalig geschrieben) exakt auf diesen `credentials=`-Pfad verweist. +/// Stabiler Pfad (nicht ein Tempfile!) - `prepare()` schreibt hierhin, `mount()` referenziert +/// denselben Pfad über `-o credentials=...`. pub fn credentials_path(pair_id: &str, side: crate::db::credentials::Side) -> PathBuf { let base = config_ctdra::get_config_path() .parent() diff --git a/src/mount/target.rs b/src/mount/target.rs index 46d4286..03e0168 100644 --- a/src/mount/target.rs +++ b/src/mount/target.rs @@ -17,10 +17,10 @@ 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::error::{Error, Result}; -use crate::mount::{self, MountInvocation, MountTarget}; +use crate::mount::{self, MountTarget}; use crate::network::address; /// 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, cached_local_ip: Option, ) -> Result { - let invocation = match pair.context { - MountContext::System => MountInvocation::Direct, - MountContext::User => MountInvocation::ViaFstab, - }; let kind = side_kind(pair, 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), source, options, - invocation, owner_user: pair.owner_user.clone(), }) } @@ -296,7 +291,6 @@ mod tests { id: "pair-1".into(), name: "Test".into(), enabled: true, - context: MountContext::System, owner_user: None, mount_point: "/media/smart-mount/pair-1".into(), local: LocalSide { diff --git a/src/mount/webdav.rs b/src/mount/webdav.rs index cbb63ad..60eedc6 100644 --- a/src/mount/webdav.rs +++ b/src/mount/webdav.rs @@ -7,9 +7,7 @@ use std::process::Command; use crate::db::credentials::Credential; use crate::error::{Error, Result}; -use crate::mount::{ - MountBackend, MountInvocation, MountTarget, binary_available, run_tolerating_already_done, -}; +use crate::mount::{MountBackend, MountTarget, binary_available, run_tolerating_already_done}; pub struct WebDavBackend; @@ -60,19 +58,12 @@ impl MountBackend for WebDavBackend { fn mount(&self, target: &MountTarget) -> Result<()> { let mut cmd = Command::new("mount"); - match target.invocation { - MountInvocation::Direct => { - cmd.arg("-t") - .arg("davfs") - .arg(&target.source) - .arg(&target.mount_point); - if !target.options.is_empty() { - cmd.arg("-o").arg(target.options.join(",")); - } - } - MountInvocation::ViaFstab => { - cmd.arg(&target.mount_point); - } + cmd.arg("-t") + .arg("davfs") + .arg(&target.source) + .arg(&target.mount_point); + if !target.options.is_empty() { + cmd.arg("-o").arg(target.options.join(",")); } run_tolerating_already_done(cmd, "davfs2 mount", false) } @@ -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 { - if sudo_ctdra::is_run_as_root() { - PathBuf::from("/etc/davfs2/davfs2.conf") - } else { - home_dir().join(".davfs2/davfs2.conf") - } + PathBuf::from("/etc/davfs2/davfs2.conf") } pub(crate) fn davfs2_secrets_path() -> PathBuf { - if sudo_ctdra::is_run_as_root() { - 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(".")) + PathBuf::from("/etc/davfs2/secrets") } /// Setzt `gui_optimize 1` in `davfs2.conf` idempotent - reduziert bei grafischen diff --git a/src/reconcile/mod.rs b/src/reconcile/mod.rs index d1c585a..af0c1c6 100644 --- a/src/reconcile/mod.rs +++ b/src/reconcile/mod.rs @@ -354,10 +354,6 @@ async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) - source: String::new(), mount_point: target::backing_dir(pair, side), 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(), } }); @@ -367,7 +363,7 @@ async fn unmount_side(pair: &DrivePair, settings: &GlobalSettings, side: Side) - #[cfg(test)] mod tests { use super::*; - use crate::config::{CloudSide, MountContext, MountKind}; + use crate::config::{CloudSide, MountKind}; use std::net::Ipv4Addr; fn sample_pair(local_kind: MountKind, cloud_kind: MountKind) -> DrivePair { @@ -375,7 +371,6 @@ mod tests { id: "pair-1".into(), name: "Test".into(), enabled: true, - context: MountContext::System, owner_user: None, mount_point: "/media/smart-mount/pair-1".into(), local: LocalSide { diff --git a/src/systemd/mod.rs b/src/systemd/mod.rs index 362fcf2..4a751da 100644 --- a/src/systemd/mod.rs +++ b/src/systemd/mod.rs @@ -1,30 +1,23 @@ -//! Generiert/installiert systemd-Units (System- und User-Kontext) sowie das Crontab-Äquivalent -//! für Systeme ohne systemd. +//! Cron-Fallback (`smart-mount service crontab`) für Systeme ohne (oder ohne genutzten) +//! systemd. //! -//! Die systemd-Units rufen ausschließlich einfache `smart-mount`-Subcommands auf, damit -//! dieselben Zeilen 1:1 als Crontab-Einträge funktionieren. +//! Die eigentliche systemd-Einrichtung passiert NICHT mehr zur Laufzeit über dieses Modul, +//! sondern über die paketierten, statischen Unit-Dateien (siehe `packaging/systemd/` im +//! Quellbaum) sowie die postinst/postrm-Skripte der .deb/.rpm/.pkg.tar.zst-Pakete - smart-mount +//! richtet sich beim Installieren des Pakets automatisch als System-systemd-Dienst ein und +//! entfernt sich beim Deinstallieren wieder. Dieses Modul bleibt für Systeme ohne systemd (oder +//! zum bewussten Umgehen von systemd) als manueller Cron-Weg bestehen - immer systemweit +//! (`/etc/cron.d/smart-mount`), da Mounten ohnehin immer Root-Rechte braucht (siehe +//! [`crate::cli::require_root`]); eine persönliche Nutzer-Crontab liefe ins Leere. -use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; 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_BEGIN_MARKER: &str = "# BEGIN 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 { std::env::current_exe() .ok() @@ -32,141 +25,15 @@ fn binary_path() -> String { .unwrap_or_else(|| "/usr/bin/smart-mount".to_string()) } -fn mount_service_unit(scope: Scope) -> String { - let wanted_by = match scope { - 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 { - 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 '. \ - 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. +/// Ob `systemctl` auf diesem System vorhanden ist - rein informativ für `doctor`; die +/// eigentliche systemd-Einrichtung läuft über die Paketierung (siehe Moduldoku). pub fn is_available() -> bool { crate::mount::binary_available("systemctl") } -/// Ergebnis von [`uninstall`]. -pub enum SystemdUninstallOutcome { - /// Mindestens eine Unit-Datei war vorhanden und wurde entfernt. - 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 { - 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. +/// Erzeugt die Crontab-Äquivalente zu den paketierten systemd-Units, für Systeme ohne +/// (genutzten) systemd. `watch_interval_secs` ist derselbe Wert wie +/// `settings.watch_interval_secs`. pub fn crontab_equivalent(watch_interval_secs: u64) -> String { let bin = binary_path(); let schedule = cron_schedule_for_interval(watch_interval_secs); @@ -177,50 +44,22 @@ pub fn crontab_equivalent(watch_interval_secs: u64) -> String { pub enum CronInstallOutcome { /// Systemweiter Eintrag geschrieben (`/etc/cron.d/smart-mount`). SystemFile(PathBuf), - /// Persönliche Crontab des aufrufenden Nutzers aktualisiert. - UserCrontab, /// Kein Cron-Mechanismus auf diesem System gefunden - nichts geschrieben, der Aufrufer /// sollte stattdessen [`crontab_equivalent`] anzeigen. Unavailable, } -/// Richtet die periodische Ausführung direkt über Cron ein (Alternative zu [`install`] für -/// Systeme ohne systemd), sofern ein Cron-Mechanismus gefunden wird - sonst [`CronInstallOutcome::Unavailable`] -/// statt eines Fehlers, der Aufrufer zeigt dann [`crontab_equivalent`] zur manuellen Einrichtung. -/// -/// `Scope::System` schreibt `/etc/cron.d/smart-mount` (Standard-Konvention für -/// paketverwaltete Cron-Einträge, läuft als root; erfordert Root-Rechte, kein -/// 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 { - 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 { - if !sudo_ctdra::is_run_as_root() { - return Err(Error::RequiresRoot("service crontab (system context)")); - } +/// Richtet die periodische Ausführung direkt über `/etc/cron.d/smart-mount` ein - manueller +/// Fallback für Systeme ohne (genutzten) systemd, sofern `/etc/cron.d` existiert - sonst +/// [`CronInstallOutcome::Unavailable`] statt eines Fehlers, der Aufrufer zeigt dann +/// [`crontab_equivalent`] zur manuellen Einrichtung. Erfordert Root (siehe Moduldoku) - der +/// Aufrufer (`cli::service`) prüft das bereits vorab. +pub fn install_cron(watch_interval_secs: u64) -> Result { if !Path::new("/etc/cron.d").is_dir() { return Ok(CronInstallOutcome::Unavailable); } - // /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root, - // passend dazu, dass System-Kontext-Paare auch sonst als root gemountet werden. - let contents = managed_cron_block(watch_interval_secs, Some("root")); + let contents = managed_cron_block(watch_interval_secs); std::fs::write(CRON_D_PATH, &contents).map_err(|e| Error::io(CRON_D_PATH, e))?; #[cfg(unix)] { @@ -231,19 +70,14 @@ fn install_cron_system(watch_interval_secs: u64) -> Result { Ok(CronInstallOutcome::SystemFile(PathBuf::from(CRON_D_PATH))) } -fn install_cron_user(watch_interval_secs: u64) -> Result { - if !crate::mount::binary_available("crontab") { - return Ok(CronInstallOutcome::Unavailable); - } - - let existing = read_current_user_crontab(); - let without_block = - 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) +fn managed_cron_block(watch_interval_secs: u64) -> String { + let bin = binary_path(); + let schedule = cron_schedule_for_interval(watch_interval_secs); + // /etc/cron.d-Zeilen brauchen (anders als persönliche Crontabs) ein Nutzerfeld - root, + // passend dazu, dass Mounts auch sonst immer als root laufen. + format!( + "{CRON_BEGIN_MARKER}\n@reboot root {bin} mount --all\n{schedule} root {bin} watch\n{CRON_END_MARKER}\n" + ) } /// Ergebnis von [`uninstall_cron`]. @@ -255,22 +89,8 @@ pub enum CronUninstallOutcome { } /// 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` - -/// nur den von smart-mount verwalteten Block in der persönlichen Crontab an, keine -/// unabhängigen, bereits vorhandenen Einträge. -pub fn uninstall_cron(scope: Scope) -> Result { - match scope { - Scope::System => uninstall_cron_system(), - Scope::User => uninstall_cron_user(), - } -} - -fn uninstall_cron_system() -> Result { - if !sudo_ctdra::is_run_as_root() { - return Err(Error::RequiresRoot( - "service uninstall (system context, cron)", - )); - } +/// Cron-Eintrag wieder, sofern vorhanden. +pub fn uninstall_cron() -> Result { let path = Path::new(CRON_D_PATH); if !path.exists() { return Ok(CronUninstallOutcome::NotPresent); @@ -279,53 +99,6 @@ fn uninstall_cron_system() -> Result { Ok(CronUninstallOutcome::Removed) } -fn uninstall_cron_user() -> Result { - 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 /// 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 @@ -350,44 +123,12 @@ mod tests { use super::*; #[test] - fn mount_service_unit_system_scope_uses_multi_user_target() { - let unit = mount_service_unit(Scope::System); - 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); + fn managed_cron_block_includes_root_user_field_and_both_lines() { + let block = managed_cron_block(120); assert!(block.starts_with(CRON_BEGIN_MARKER)); assert!(block.trim_end().ends_with(CRON_END_MARKER)); - assert!(block.contains("@reboot") && !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("mount --all")); assert!(block.contains("*/2 * * * * root ")); } diff --git a/src/util.rs b/src/util.rs deleted file mode 100644 index 7870fc9..0000000 --- a/src/util.rs +++ /dev/null @@ -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 { - 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 - ); - } -}