diff --git a/Cargo.lock b/Cargo.lock index 7c55c89..2f1a4fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,188 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[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", + "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", +] + +[[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.117", +] + +[[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", + "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.3", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bread-shared" version = "0.7.0" @@ -83,6 +265,7 @@ dependencies = [ "serde", "serde_json", "toml", + "zbus", ] [[package]] @@ -91,6 +274,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "clap" version = "4.6.1" @@ -122,7 +311,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -137,6 +326,50 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "dirs" version = "5.0.1" @@ -158,12 +391,137 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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.117", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "futures-core" +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-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -175,6 +533,17 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -187,6 +556,18 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "indexmap" version = "2.14.0" @@ -224,12 +605,46 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" @@ -242,6 +657,71 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[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 = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -260,17 +740,66 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "serde" version = "1.0.228" @@ -298,7 +827,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -314,6 +843,17 @@ 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.3", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -323,6 +863,39 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -340,6 +913,30 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -357,7 +954,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -368,8 +965,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -381,6 +978,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -390,9 +996,30 @@ dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -401,6 +1028,54 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +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 = "unicode-ident" version = "1.0.24" @@ -413,6 +1088,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -431,7 +1112,25 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -449,13 +1148,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -464,42 +1179,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -509,8 +1272,146 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix", + "ordered-stream", + "rand", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/Cargo.toml b/Cargo.toml index 3375a4b..59d3fb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } toml = "0.8" serde_json = "1" +zbus = "4" bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.2", features = ["bread-client"] } [profile.release] diff --git a/README.md b/README.md index adf40f9..d8877f5 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,21 @@ A profile-aware Wi-Fi state machine for Linux with Tailscale exit-node management and a self-healing watch daemon. -breadcrumbs sits on top of NetworkManager (`nmcli`) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically. +breadcrumbs sits on top of NetworkManager's **D-Bus API** (`org.freedesktop.NetworkManager` on the system bus — no `nmcli` subprocesses) and manages your Wi-Fi based on **location profiles**. Switch between home, work, school, or any other context with a single command — it handles scanning, connecting, DNS pinning, and Tailscale setup automatically. ## Features - **Profile-based connection management** — define ordered network priority lists per location - **Bootstrap + Tailscale gating** — connect to an interim network first, bring up Tailscale, then move to the target network -- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via `nmcli monitor` +- **Self-healing watch daemon** — monitors for drops, auto-recovers, reacts within seconds via NetworkManager D-Bus signals - **Auto-detection** — scans visible SSIDs and guesses your location from config-defined markers -- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). On that first connect the PSK is fed to `nmcli --ask` on stdin, never as a command argument, so it does not appear in `/proc//cmdline`. +- **Credential handling** — a saved network's password is only needed the *first* time breadcrumbs connects to it. Once that connect succeeds, NetworkManager durably owns the credential (a new connection profile, or an updated PSK on an existing one), so breadcrumbs clears its own local copy and stops writing it to disk. Both config files are `0600` (owner-only); saved networks live in a separate `networks.toml` from settings/profiles (see [Configuration](#configuration)). Secrets are never exposed in a process command line: everything travels inside D-Bus `Update2`/`AddAndActivateConnection2` settings payloads, invisible to other local users via `/proc//cmdline`. - **Desktop notifications** via `notify-send` (optional) - **systemd user service** generation via `breadcrumbs install-service` ## Requirements -- Linux with NetworkManager (`nmcli` in `$PATH`) +- Linux with NetworkManager running on the D-Bus system bus - Rust toolchain (to build from source) - `tailscale` (optional — only needed if any profile sets `tailscale = true`) - `notify-send` (optional — for desktop notifications) @@ -52,7 +52,7 @@ Settings and location profiles live in `breadcrumbs.toml` — the file people ac ```toml [settings] dns = "1.1.1.1" # DNS server pinned on every connection -nmcli_wait = 8 # seconds to wait for nmcli connect +connect_wait = 8 # seconds to wait for the device to reach the activated state (legacy key: nmcli_wait) exit_node = "myhostname" # default Tailscale exit node exit_nodes = ["a", "b"] # optional priority list; tried in order (fallback nodes) interface = "wlan0" # optional preferred Wi-Fi interface @@ -169,7 +169,7 @@ breadcrumbs install-service `breadcrumbs watch` is the recommended way to run breadcrumbs for daily use. It: 1. Polls health every `watch_interval` seconds (adaptive backoff on repeated failures) -2. Reacts immediately to link-state changes via `nmcli monitor` +2. Reacts immediately to link-state changes via NetworkManager D-Bus signals (`Device.StateChanged`, `Connectivity` property changes, hotplug events) 3. Runs `flow::run` (the connect state machine) on any detected drop 4. Handles profile changes live — re-reads config and state on every tick 5. Distinguishes captive portals from plain no-internet (a 200/301/302 instead diff --git a/breadcrumbs.example.toml b/breadcrumbs.example.toml index 57d820c..745fb50 100644 --- a/breadcrumbs.example.toml +++ b/breadcrumbs.example.toml @@ -13,7 +13,7 @@ [settings] dns = "1.1.1.1" -nmcli_wait = 8 +connect_wait = 8 exit_node = "my-exit-node" # Tailscale hostname of your preferred exit node default_profile = "away" watch_interval = 12 diff --git a/src/app.rs b/src/app.rs index ef78276..6f78ea0 100644 --- a/src/app.rs +++ b/src/app.rs @@ -465,8 +465,9 @@ fn prompt_line(msg: &str) -> String { /// response) means "this network has no password" (open Wi-Fi) — normalize /// it to `None` right at the point of entry so it flows the same way a /// genuinely absent/cleared password does. Without this, `Some("")` would -/// make `nm::connect_verbose` treat it as a (blank) secret rather than an -/// open network, and the connect fails against a real open SSID. +/// make `nm::connect_verbose` send an empty PSK in the settings payload, +/// which NetworkManager treats as "secured with a blank password" rather +/// than "open", and the connect fails against a real open SSID. fn non_empty(s: String) -> Option { if s.is_empty() { None @@ -666,7 +667,7 @@ fn cmd_scan(cfg: &mut Config, to: Option) -> Result { ca_cert: None, hidden: false, }; - if !nm::connect(&iface, &def, cfg.settings.nmcli_wait, &cfg.settings.dns) { + if !nm::connect(&iface, &def, cfg.settings.connect_wait, &cfg.settings.dns) { return Err(format!("failed to connect to {ssid}")); } // A successful connect means NetworkManager now durably holds the PSK @@ -799,9 +800,9 @@ fn cmd_doctor(cfg: &Config, override_p: &Option, full: bool) -> Result Result { // session's DISPLAY/WAYLAND_DISPLAY/DBUS so notify-send and the Tailscale // login browser-open actually work. PATH is pinned because systemd --user // units do not get the login shell's PATH, and the watcher shells out to - // nmcli/tailscale/sudo/xdg-open by name. + // tailscale/sudo/xdg-open by name (NetworkManager is reached over D-Bus, + // so no nmcli is needed). let unit = format!( "[Unit]\n\ Description=breadcrumbs Wi-Fi state machine watcher\n\ diff --git a/src/config.rs b/src/config.rs index 4024af7..11ed02f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use crate::util::home_dir; fn default_dns() -> String { "1.1.1.1".to_string() } -fn default_nmcli_wait() -> u32 { +fn default_connect_wait() -> u32 { 8 } fn default_exit_node() -> String { @@ -77,8 +77,10 @@ fn is_false(b: &bool) -> bool { pub struct Settings { #[serde(default = "default_dns")] pub dns: String, - #[serde(default = "default_nmcli_wait")] - pub nmcli_wait: u32, + /// Seconds to wait for a connect to reach the ACTIVATED device state. + /// `nmcli_wait` is accepted as a legacy alias. + #[serde(default = "default_connect_wait", alias = "nmcli_wait")] + pub connect_wait: u32, #[serde(default = "default_exit_node")] pub exit_node: String, #[serde(default = "default_profile_name")] @@ -118,7 +120,7 @@ impl Default for Settings { fn default() -> Self { Settings { dns: default_dns(), - nmcli_wait: default_nmcli_wait(), + connect_wait: default_connect_wait(), exit_node: default_exit_node(), default_profile: default_profile_name(), watch_interval: default_watch_interval(), @@ -560,7 +562,7 @@ mod tests { fn settings_default_matches_documented_defaults() { let s = Settings::default(); assert_eq!(s.dns, "1.1.1.1"); - assert_eq!(s.nmcli_wait, 8); + assert_eq!(s.connect_wait, 8); assert_eq!(s.default_profile, "away"); assert_eq!(s.watch_interval, 12); assert_eq!(s.ping_host, "1.1.1.1"); diff --git a/src/flow.rs b/src/flow.rs index 8e9c91b..469a3cf 100644 --- a/src/flow.rs +++ b/src/flow.rs @@ -97,7 +97,7 @@ fn learn_ssid(cfg: &mut Config, profile: &str, ssid: &str) { /// Try to connect + confirm the device actually landed on the *requested* /// SSID. Returns Ok(()) on success, Err(reason) on failure. fn connect_and_verify(iface: &str, def: &NetworkDef, cfg: &Config) -> Result<(), String> { - nm::connect_verbose(iface, def, cfg.settings.nmcli_wait, def.effective_dns(&cfg.settings.dns))?; + nm::connect_verbose(iface, def, cfg.settings.connect_wait, def.effective_dns(&cfg.settings.dns))?; // Confirm the SSID, not just "device connected": NM autoconnect can win // a race and leave the device on a different network, and the wifi list // can lag activation by a moment — so poll briefly before giving up. diff --git a/src/nm.rs b/src/nm.rs index c8d3878..38f5e42 100644 --- a/src/nm.rs +++ b/src/nm.rs @@ -1,157 +1,262 @@ +//! All NetworkManager access goes over its D-Bus API +//! (`org.freedesktop.NetworkManager` on the system bus) — no `nmcli` +//! subprocess and no terse-output parsing. The D-Bus API returns structured +//! values (SSID as a byte array, signal strength as `u8`, device state as a +//! `u32` enum), so there is nothing like the old `-t` escaping to get wrong. +//! +//! Every function here is fail-silent in the same spirit as the old nmcli +//! calls: a missing bus / unreachable NetworkManager yields `None`/`false`/ +//! an empty collection, never a panic. The one exception is +//! [`connect_verbose`], which returns the D-Bus error text so callers can +//! surface *why* a connect failed. +//! +//! The client connects with [`zbus::blocking::Connection::system`], which +//! honors the standard `DBUS_SYSTEM_BUS_ADDRESS` environment variable — the +//! test suite uses that to point at a fake NetworkManager served on a +//! private bus, with no test-only code paths in here. + use std::collections::{HashMap, HashSet}; -use std::time::Duration; +use std::time::{Duration, Instant}; + +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value}; use crate::config::NetworkDef; -use crate::util::{run, run_ok}; -/// nmcli `-t` escapes `:` and `\` in field values; undo that. -fn unescape(s: &str) -> String { - let mut out = String::with_capacity(s.len()); - let mut chars = s.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\\' { - if let Some(&n) = chars.peek() { - out.push(n); - chars.next(); - continue; - } - } - out.push(c); - } - out +const NM_DEST: &str = "org.freedesktop.NetworkManager"; +const NM_PATH: &str = "/org/freedesktop/NetworkManager"; +const NM_IFACE: &str = "org.freedesktop.NetworkManager"; +const DEV_IFACE: &str = "org.freedesktop.NetworkManager.Device"; +const WIFI_IFACE: &str = "org.freedesktop.NetworkManager.Device.Wireless"; +const AP_IFACE: &str = "org.freedesktop.NetworkManager.AccessPoint"; +const SETTINGS_PATH: &str = "/org/freedesktop/NetworkManager/Settings"; +const SETTINGS_IFACE: &str = "org.freedesktop.NetworkManager.Settings"; +const CONN_IFACE: &str = "org.freedesktop.NetworkManager.Settings.Connection"; +const ACTIVE_IFACE: &str = "org.freedesktop.NetworkManager.Connection.Active"; +const IP4_IFACE: &str = "org.freedesktop.NetworkManager.IP4Config"; + +// NM_DEVICE_TYPE_WIFI +const DEV_TYPE_WIFI: u32 = 2; +// NM_DEVICE_STATE_ACTIVATED +const DEV_STATE_ACTIVATED: u32 = 100; +// NM_802_11_AP_FLAGS_PRIVACY +const AP_FLAG_PRIVACY: u32 = 0x1; +// NM_802_11_AP_SEC_KEY_MGMT_PSK / SAE / 802_1X +const SEC_PSK: u32 = 0x100; +const SEC_802_1X: u32 = 0x200; +const SEC_SAE: u32 = 0x400; +// NM_SETTINGS_ADD_CONNECTION2_FLAG_TO_DISK / UPDATE2_FLAG_TO_DISK +const FLAG_TO_DISK: u32 = 0x1; + +/// A fresh connection to the system bus (or wherever `DBUS_SYSTEM_BUS_ADDRESS` +/// points). Created per call — cheap relative to the subprocess the old code +/// spawned — and immune to environment changes between calls. +fn connection() -> Option { + Connection::system().ok() } -/// Split one nmcli `-t` line into fields. Fields are ':'-separated but values -/// escape ':' as '\:' and '\' as '\\' — a plain `splitn(2, ':')` mis-splits -/// any field (device name, connection name, SSID, …) that legitimately -/// contains a colon, so every terse-output parse in this module goes through -/// here rather than splitting on raw bytes. Fields are returned unescaped. -fn split_fields(line: &str) -> Vec { - let mut fields: Vec = Vec::new(); - let mut cur = String::new(); - let mut chars = line.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\\' { - if let Some(&n) = chars.peek() { - cur.push(n); - chars.next(); - continue; +/// zbus error text, for the one call that surfaces it. +fn err_text(e: zbus::Error) -> String { + format!("D-Bus: {e}") +} + +/// Extract a byte array (SSID / CA cert blob) from a settings dict value. +/// zvariant has no `TryFrom<&Value>` for `Vec` (only for owned `Value`), +/// so we peel the `Array` ourselves. +fn value_bytes(v: &Value) -> Option> { + match v { + Value::Array(a) => { + let mut out = Vec::new(); + for item in a.inner() { + if let Value::U8(b) = item { + out.push(*b); + } else { + return None; + } } + Some(out) } - if c == ':' { - fields.push(std::mem::take(&mut cur)); - } else { - cur.push(c); + _ => None, + } +} + +/// Wrap an owned [`Value`] as an [`OwnedValue`] for storage in a settings +/// dict. Only fails for exotic non-ownable values (FDs); our data never hits +/// that, so a panic here would be a genuine bug. +fn ov(v: Value<'_>) -> OwnedValue { + OwnedValue::try_from(v).expect("settings value is ownable") +} + +fn proxy<'a>(conn: &'a Connection, path: &'a str, iface: &'a str) -> Option> { + Proxy::new(conn, NM_DEST, path, iface).ok() +} + +fn nm_proxy(conn: &Connection) -> Option> { + proxy(conn, NM_PATH, NM_IFACE) +} + +/// All realized device object paths (GetDevices), empty on error. +fn devices(conn: &Connection) -> Vec { + nm_proxy(conn) + .and_then(|p| p.call("GetDevices", &()).ok()) + .unwrap_or_default() +} + +/// The object path of the device whose `Interface` property is `iface`. +fn device_path(conn: &Connection, iface: &str) -> Option { + for d in devices(conn) { + // Scope the proxy so its borrow of `d` ends before we move `d` out. + let name: String = { + let dev = proxy(conn, d.as_str(), DEV_IFACE)?; + dev.get_property("Interface").ok()? + }; + if name == iface { + return Some(d); } } - fields.push(cur); - fields + None +} + +/// All access points visible to a Wi-Fi device, as `(path, ssid, strength, +/// flags, wpa_flags, rsn_flags)` tuples (SSID decoded lossily from bytes). +fn access_points(conn: &Connection, iface: &str) -> Vec<(String, String, u8, u32, u32, u32)> { + let dev = match device_path(conn, iface) { + Some(d) => d, + None => return Vec::new(), + }; + let wifi = match proxy(conn, dev.as_str(), WIFI_IFACE) { + Some(p) => p, + None => return Vec::new(), + }; + let aps: Vec = match wifi.call("GetAllAccessPoints", &()) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; + aps.into_iter() + .filter_map(|ap| { + let p = proxy(conn, ap.as_str(), AP_IFACE)?; + let ssid: Vec = p.get_property("Ssid").ok()?; + let strength: u8 = p.get_property("Strength").ok()?; + let flags: u32 = p.get_property("Flags").ok()?; + let wpa: u32 = p.get_property("WpaFlags").ok()?; + let rsn: u32 = p.get_property("RsnFlags").ok()?; + Some(( + ap.as_str().to_string(), + String::from_utf8_lossy(&ssid).into_owned(), + strength, + flags, + wpa, + rsn, + )) + }) + .collect() } pub fn wifi_interface() -> Option { wifi_interface_preferred(None) } +/// Object paths of every Wi-Fi device. The watch loop's monitor subscribes +/// to `Device.StateChanged` on each of these so link churn wakes it early. +pub fn wifi_device_paths() -> Vec { + let Some(conn) = connection() else { + return Vec::new(); + }; + let mut out = Vec::new(); + for d in devices(&conn) { + let dev = match proxy(&conn, d.as_str(), DEV_IFACE) { + Some(p) => p, + None => continue, + }; + if matches!(dev.get_property::("DeviceType"), Ok(t) if t == DEV_TYPE_WIFI) { + out.push(d.as_str().to_string()); + } + } + out +} + +/// Whether NetworkManager itself is present and answering on the system bus +/// (or wherever `DBUS_SYSTEM_BUS_ADDRESS` points). Used by `doctor` to +/// report NetworkManager presence without shelling out. A reachable bus with +/// no NM service (or a non-NM service under the name) reports false. +pub fn available() -> bool { + let Some(conn) = connection() else { + return false; + }; + let Some(p) = nm_proxy(&conn) else { + return false; + }; + // A well-known-name lookup that actually gets a reply proves the real + // NetworkManager holds the name. + p.call::<_, _, Vec>("GetDevices", &()).is_ok() +} + /// Find the Wi-Fi interface. When `pref` is `Some`, that exact device is /// used if present; otherwise (or if the preferred device is missing — e.g. /// an unplugged USB dongle) the first Wi-Fi device wins. pub fn wifi_interface_preferred(pref: Option<&str>) -> Option { - let o = run( - "nmcli", - &["-t", "-f", "DEVICE,TYPE", "device", "status"], - Duration::from_secs(8), - ); - if !o.success { - return None; - } - let mut devices: Vec = Vec::new(); - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.len() >= 2 && fields[1] == "wifi" { - devices.push(fields[0].clone()); + let conn = connection()?; + let mut wifi: Vec = Vec::new(); + for d in devices(&conn) { + let dev = proxy(&conn, d.as_str(), DEV_IFACE)?; + let devtype: u32 = dev.get_property("DeviceType").ok()?; + if devtype == DEV_TYPE_WIFI { + let name: String = dev.get_property("Interface").ok()?; + wifi.push(name); } } if let Some(p) = pref { - if devices.iter().any(|d| d == p) { + if wifi.iter().any(|d| d == p) { return Some(p.to_string()); } } - devices.into_iter().next() + wifi.into_iter().next() } pub fn radio_on() { - let _ = run("nmcli", &["radio", "wifi", "on"], Duration::from_secs(6)); + let Some(conn) = connection() else { return }; + let Some(p) = nm_proxy(&conn) else { return }; + let _ = p.set_property("WirelessEnabled", true); } pub fn rescan(iface: &str, ssids: &[String]) { - let mut args: Vec = vec![ - "device".into(), - "wifi".into(), - "rescan".into(), - "ifname".into(), - iface.into(), - ]; - for s in ssids { - args.push("ssid".into()); - args.push(s.clone()); + let Some(conn) = connection() else { return }; + let Some(dev) = device_path(&conn, iface) else { return }; + let Some(wifi) = proxy(&conn, dev.as_str(), WIFI_IFACE) else { + return; + }; + let mut options: HashMap = HashMap::new(); + if !ssids.is_empty() { + let ssid_bytes: Vec> = ssids.iter().map(|s| s.as_bytes().to_vec()).collect(); + options.insert("ssids".into(), Value::from(ssid_bytes)); } - let argv: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - let _ = run("nmcli", &argv, Duration::from_secs(20)); + let _ = wifi.call_noreply("RequestScan", &(options,)); } pub fn visible_ssids(iface: &str) -> HashSet { - let o = run( - "nmcli", - &[ - "-t", "-f", "SSID", "device", "wifi", "list", "ifname", iface, - ], - Duration::from_secs(12), - ); - let mut set = HashSet::new(); - if !o.success { - return set; - } - for line in o.stdout.lines() { - let ssid = unescape(line.trim()); - if !ssid.is_empty() { - set.insert(ssid); - } - } - set + let Some(conn) = connection() else { + return HashSet::new(); + }; + access_points(&conn, iface) + .into_iter() + .map(|(_, ssid, _, _, _, _)| ssid) + .filter(|s| !s.is_empty()) + .collect() } /// Visible SSIDs with their signal strength (0–100), one entry per SSID /// (strongest BSSID wins). Used for signal-aware network selection and /// scored detection. pub fn visible_signals(iface: &str) -> HashMap { - let o = run( - "nmcli", - &[ - "-t", - "-f", - "SSID,SIGNAL", - "device", - "wifi", - "list", - "ifname", - iface, - ], - Duration::from_secs(12), - ); + let Some(conn) = connection() else { + return HashMap::new(); + }; let mut m: HashMap = HashMap::new(); - if !o.success { - return m; - } - for line in o.stdout.lines() { - let f = split_fields(line); - if f.len() < 2 { - continue; - } - let ssid = f[0].trim().to_string(); + for (_, ssid, strength, _, _, _) in access_points(&conn, iface) { if ssid.is_empty() { continue; } - let sig = signal_strength(&f[1]); + let sig = strength as i32; m.entry(ssid) .and_modify(|e| { if sig > *e { @@ -170,53 +275,50 @@ pub struct ScanEntry { pub security: String, } -/// Parse an nmcli SIGNAL value ("72" or "72 %") into a comparable number. -fn signal_strength(s: &str) -> i32 { - s.trim() - .trim_end_matches('%') - .trim() - .parse::() - .unwrap_or(-100) +/// Derive an nmcli-style SECURITY column value from the 802.11 flag sets. +/// Mirrors what nmcli shows for an AP: `--` for open, `WPA1 WPA2` / `WPA2` / +/// `WPA3` / `802.1X` / `WEP` for secured networks. +fn security_string(flags: u32, wpa: u32, rsn: u32) -> String { + if flags & AP_FLAG_PRIVACY == 0 && wpa == 0 && rsn == 0 { + return "--".into(); + } + if rsn & SEC_SAE != 0 { + return "WPA3".into(); + } + let mut parts: Vec<&str> = Vec::new(); + if wpa & SEC_PSK != 0 { + parts.push("WPA1"); + } + if rsn & SEC_PSK != 0 { + parts.push("WPA2"); + } + if !parts.is_empty() { + return parts.join(" "); + } + if wpa & SEC_802_1X != 0 || rsn & SEC_802_1X != 0 { + return "802.1X".into(); + } + "WEP".into() } pub fn scan_list(iface: &str) -> Vec { - let o = run( - "nmcli", - &[ - "-t", - "-f", - "SSID,SIGNAL,SECURITY", - "device", - "wifi", - "list", - "ifname", - iface, - ], - Duration::from_secs(12), - ); + let Some(conn) = connection() else { + return Vec::new(); + }; let mut out: Vec = Vec::new(); - if !o.success { - return out; - } - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.is_empty() { - continue; - } - let ssid = fields[0].trim().to_string(); + for (_, ssid, strength, flags, wpa, rsn) in access_points(&conn, iface) { if ssid.is_empty() { // Hidden networks have no SSID in the scan; they're not // selectable here anyway (see `cmd_scan`), so skip them. continue; } - let signal = fields.get(1).cloned().unwrap_or_default(); - let security = fields.get(2).cloned().unwrap_or_default(); - // One line per BSSID: dedup by SSID keeping the *strongest* signal, - // so a network broadcast by several APs shows once (at its best - // signal) instead of N times at the first listing. + let signal = strength.to_string(); + let security = security_string(flags, wpa, rsn); + // One entry per SSID: dedup keeping the *strongest* signal, so a + // network broadcast by several APs shows once (at its best signal). match out.iter_mut().find(|e| e.ssid == ssid) { Some(existing) => { - if signal_strength(&signal) > signal_strength(&existing.signal) { + if signal_rank(&signal) > signal_rank(&existing.signal) { existing.signal = signal; existing.security = security; } @@ -231,124 +333,130 @@ pub fn scan_list(iface: &str) -> Vec { out } +/// Numeric rank of a signal string ("72" or "72 %" — the fake NM can emit +/// either), for the strongest-wins dedup. +fn signal_rank(s: &str) -> i32 { + s.trim() + .trim_end_matches('%') + .trim() + .parse::() + .unwrap_or(-100) +} + pub fn active_ssid(iface: &str) -> Option { - let o = run( - "nmcli", - &[ - "-t", - "-f", - "ACTIVE,SSID", - "device", - "wifi", - "list", - "ifname", - iface, - ], - Duration::from_secs(8), - ); - if !o.success { + let conn = connection()?; + let dev = device_path(&conn, iface)?; + let wifi = proxy(&conn, dev.as_str(), WIFI_IFACE)?; + let ap: OwnedObjectPath = wifi.get_property("ActiveAccessPoint").ok()?; + if ap.as_str() == "/" { return None; } - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.len() >= 2 && fields[0] == "yes" { - let s = fields[1].trim().to_string(); - if !s.is_empty() { - return Some(s); - } - } + let p = proxy(&conn, ap.as_str(), AP_IFACE)?; + let ssid: Vec = p.get_property("Ssid").ok()?; + let s = String::from_utf8_lossy(&ssid).into_owned(); + if s.is_empty() { + None + } else { + Some(s) } - None +} + +/// The device's current IPv4 address (dotted quad), via `Device.Ip4Config` +/// → `IP4Config.Addresses` (first address). Replaces the old +/// `nmcli -g IP4.ADDRESS device show` call. +pub fn ipv4_address(iface: &str) -> Option { + let conn = connection()?; + let dev = device_path(&conn, iface)?; + let dev_proxy = proxy(&conn, dev.as_str(), DEV_IFACE)?; + let cfg_path: OwnedObjectPath = dev_proxy.get_property("Ip4Config").ok()?; + if cfg_path.as_str() == "/" { + return None; + } + let ip4 = proxy(&conn, cfg_path.as_str(), IP4_IFACE)?; + // a(ayu): each entry is (address, prefix, gateway), address in network + // byte order as a host-order u32. + let addresses: Vec<(u32, u32, u32)> = ip4.get_property("Addresses").ok()?; + let (ip, _, _) = addresses.into_iter().next()?; + Some(format!( + "{}.{}.{}.{}", + (ip >> 24) & 0xff, + (ip >> 16) & 0xff, + (ip >> 8) & 0xff, + ip & 0xff + )) } pub fn device_connected(iface: &str) -> bool { - let o = run( - "nmcli", - &["-t", "-f", "DEVICE,STATE", "device", "status"], - Duration::from_secs(6), - ); - if !o.success { + let Some(conn) = connection() else { return false; - } - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.len() >= 2 && fields[0] == iface { - return fields[1].starts_with("connected"); - } - } - false + }; + let Some(dev) = device_path(&conn, iface) else { + return false; + }; + let Some(p) = proxy(&conn, dev.as_str(), DEV_IFACE) else { + return false; + }; + matches!(p.get_property::("State"), Ok(s) if s == DEV_STATE_ACTIVATED) } -fn active_uuid(iface: &str) -> Option { - let o = run( - "nmcli", - &["-g", "GENERAL.CON-UUID", "device", "show", iface], - Duration::from_secs(6), - ); - if !o.success { +/// The object path of the *active* connection's settings profile (via +/// Device.ActiveConnection → Connection.Active.Connection), if the device is +/// up. Used to locate the profile to pin DNS onto. +fn active_connection_path(conn: &Connection, iface: &str) -> Option { + let dev = device_path(conn, iface)?; + let dev_proxy = proxy(conn, dev.as_str(), DEV_IFACE)?; + let active: OwnedObjectPath = dev_proxy.get_property("ActiveConnection").ok()?; + if active.as_str() == "/" { return None; } - let u = o.stdout.trim().to_string(); - if u.is_empty() { - None - } else { - Some(u) - } + let ac = proxy(conn, active.as_str(), ACTIVE_IFACE)?; + let conn_path: OwnedObjectPath = ac.get_property("Connection").ok()?; + Some(conn_path) } -fn enforce_dns(uuid: &str, iface: &str, dns: &str) { - if dns.trim().is_empty() { - return; - } - let ok = run_ok( - "nmcli", - &[ - "connection", - "modify", - uuid, - "ipv4.ignore-auto-dns", - "yes", - "ipv4.dns", - dns, - ], - Duration::from_secs(8), - ); - if ok { - let _ = run( - "nmcli", - &["device", "reapply", iface], - Duration::from_secs(8), - ); - } +/// Full settings dict of a saved connection profile. +type SettingsMap = HashMap>; + +fn get_settings(conn: &Connection, conn_path: &str) -> Option { + let p = proxy(conn, conn_path, CONN_IFACE)?; + p.call("GetSettings", &()).ok() } -/// Return the name of the first saved NM connection profile whose name is -/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention). -/// Returns `None` if no such profile exists. -fn first_profile_for_ssid(ssid: &str) -> Option { - let o = run( - "nmcli", - &["-t", "-f", "NAME,TYPE", "connection", "show"], - Duration::from_secs(8), - ); - if !o.success { - return None; - } - let mut fallback: Option = None; - for line in o.stdout.lines() { - let fields = split_fields(line); - if fields.len() < 2 || !fields[1].contains("wireless") { +/// Return the path of the first saved NM connection profile whose name is +/// either exactly `ssid` or `ssid N` (NM's numbered-duplicate convention), or +/// whose 802-11-wireless SSID equals `ssid`. Returns `None` if no such +/// profile exists. +fn first_profile_for_ssid(conn: &Connection, ssid: &str) -> Option { + let settings = proxy(conn, SETTINGS_PATH, SETTINGS_IFACE)?; + let conns: Vec = settings.call("ListConnections", &()).ok()?; + let mut fallback: Option = None; + for c in conns { + let Some(s) = get_settings(conn, c.as_str()) else { continue; + }; + let conn_id = s + .get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()); + let conn_ssid = s + .get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(|v| value_bytes(v)) + .map(|b| String::from_utf8_lossy(&b).into_owned()); + match &conn_id { + Some(id) if id == ssid => return Some(c), + _ => {} } - let name = fields[0].clone(); - if name == ssid { - return Some(name); + if conn_ssid.as_deref() == Some(ssid) { + return Some(c); } if fallback.is_none() { - if let Some(suffix) = name.strip_prefix(ssid) { - let s = suffix.trim(); - if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) { - fallback = Some(name); + if let Some(id) = &conn_id { + if let Some(suffix) = id.strip_prefix(ssid) { + let s = suffix.trim(); + if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) { + fallback = Some(c); + } } } } @@ -356,254 +464,279 @@ fn first_profile_for_ssid(ssid: &str) -> Option { fallback } +/// Build the `a{sa{sv}}` settings dict for a new Wi-Fi connection. +fn wifi_settings(net: &NetworkDef, uuid: &str) -> SettingsMap { + let mut settings: SettingsMap = HashMap::new(); + + let mut conn: HashMap = HashMap::new(); + conn.insert("id".into(), ov(Value::from(net.ssid.clone()))); + conn.insert("type".into(), ov(Value::from("802-11-wireless"))); + conn.insert("uuid".into(), ov(Value::from(uuid.to_string()))); + settings.insert("connection".into(), conn); + + let mut wifi: HashMap = HashMap::new(); + wifi.insert("ssid".into(), ov(Value::from(net.ssid.as_bytes().to_vec()))); + wifi.insert("mode".into(), ov(Value::from("infrastructure"))); + wifi.insert("hidden".into(), ov(Value::from(net.hidden))); + settings.insert("802-11-wireless".into(), wifi); + + if net.eap.is_some() { + let mut sec: HashMap = HashMap::new(); + sec.insert("key-mgmt".into(), ov(Value::from("wpa-eap"))); + settings.insert("802-11-wireless-security".into(), sec); + + let mut x1: HashMap = HashMap::new(); + if let Some(eap) = &net.eap { + x1.insert("eap".into(), ov(Value::from(vec![eap.clone()]))); + } + if let Some(id) = &net.identity { + x1.insert("identity".into(), ov(Value::from(id.clone()))); + } + if let Some(ca) = &net.ca_cert { + // NM stores `802-1x.ca-cert` as a GBytes (`ay`). For a filesystem + // path we send the conventional `file://` URI — the same form + // `nmcli` persists when given a path — not a bare string. + x1.insert( + "ca-cert".into(), + ov(Value::from(format!("file://{ca}").into_bytes())), + ); + } + if let Some(pw) = &net.password { + x1.insert("password".into(), ov(Value::from(pw.clone()))); + } + settings.insert("802-1x".into(), x1); + } else if let Some(pw) = &net.password { + let mut sec: HashMap = HashMap::new(); + sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk"))); + sec.insert("psk".into(), ov(Value::from(pw.clone()))); + settings.insert("802-11-wireless-security".into(), sec); + } + + let mut ipv4: HashMap = HashMap::new(); + ipv4.insert("method".into(), ov(Value::from("auto"))); + settings.insert("ipv4".into(), ipv4); + let mut ipv6: HashMap = HashMap::new(); + ipv6.insert("method".into(), ov(Value::from("auto"))); + settings.insert("ipv6".into(), ipv6); + + settings +} + +/// Update a saved profile's settings in place for `net`: PSK (or 802.1x +/// properties for enterprise networks) and the hidden flag. Returns the +/// updated dict. +fn updated_settings_for(net: &NetworkDef, s: &mut SettingsMap) { + let sec = s.entry("802-11-wireless-security".to_string()).or_default(); + if net.eap.is_some() { + sec.insert("key-mgmt".into(), ov(Value::from("wpa-eap"))); + } else if net.password.is_some() { + sec.insert("key-mgmt".into(), ov(Value::from("wpa-psk"))); + } + if let Some(pw) = &net.password { + if net.eap.is_some() { + let x1 = s.entry("802-1x".to_string()).or_default(); + x1.insert("password".into(), ov(Value::from(pw.clone()))); + } else { + sec.insert("psk".into(), ov(Value::from(pw.clone()))); + } + } + let wifi = s.entry("802-11-wireless".to_string()).or_default(); + wifi.insert("hidden".into(), ov(Value::from(net.hidden))); +} + +/// RFC 4122 v4 UUID from `/dev/urandom` — used for the `connection.uuid` of +/// newly created profiles. (NetworkManager would generate one itself if +/// omitted, but being explicit matches `nmcli` and keeps the fake NM simple.) +fn new_uuid() -> String { + let mut bytes = [0u8; 16]; + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { + use std::io::Read; + let _ = f.read_exact(&mut bytes); + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + format!( + "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], + bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15] + ) +} + +/// Wait up to `wait` seconds for the device to reach ACTIVATED. Polls the +/// device State property, like `nmcli --wait` blocks for activation. +fn wait_activated(conn: &Connection, dev: &OwnedObjectPath, wait: u32) -> Result<(), String> { + let deadline = Instant::now() + Duration::from_secs(wait.max(1) as u64); + loop { + match proxy(conn, dev.as_str(), DEV_IFACE) { + Some(p) => match p.get_property::("State") { + Ok(s) if s == DEV_STATE_ACTIVATED => return Ok(()), + Ok(_) => {} + Err(e) => return Err(err_text(e)), + }, + None => return Err("device disappeared while waiting for activation".into()), + } + if Instant::now() >= deadline { + return Err(format!( + "timed out waiting for device {dev} to activate after {wait}s" + )); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// Pin DNS onto the active connection of `iface`: update `ipv4.ignore-auto-dns` +/// and `ipv4.dns`, persist via Update2, then reapply on the device. +fn enforce_dns(conn: &Connection, iface: &str, dns: &str) -> bool { + if dns.trim().is_empty() { + return true; + } + let Some(active) = active_connection_path(conn, iface) else { + return false; + }; + let Some(mut s) = get_settings(conn, active.as_str()) else { + return false; + }; + let ipv4 = s.entry("ipv4".to_string()).or_default(); + ipv4.insert("ignore-auto-dns".into(), ov(Value::from(true))); + ipv4.insert("dns".into(), ov(Value::from(vec![dns.to_string()]))); + + // Persist the change on the *profile* object via `Update2` — calling + // `Settings.AddConnection2` with the profile's own UUID would fail on + // real NetworkManager (NM_SETTINGS_ERROR_UUID_EXISTS; duplicates are + // rejected, not upserted). + let conn_proxy = match proxy(conn, active.as_str(), CONN_IFACE) { + Some(p) => p, + None => return false, + }; + let _: Result, _> = conn_proxy.call( + "Update2", + &(&s, FLAG_TO_DISK, &HashMap::::new()), + ); + + // Reapply the updated settings on the device so DNS takes effect without + // bouncing the link. + let Some(dev) = device_path(conn, iface) else { + return false; + }; + let Some(dev_proxy) = proxy(conn, dev.as_str(), DEV_IFACE) else { + return false; + }; + let _: Result<(), _> = dev_proxy.call("Reapply", &(&s, 0u64, 0u32)); + true +} + /// Connect to a network and pin DNS. Returns true only if associated. pub fn connect(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> bool { connect_verbose(iface, net, wait, dns).is_ok() } -/// Connect to a network and pin DNS. Returns the nmcli error on failure. +/// Connect to a network and pin DNS. Returns the D-Bus error on failure. /// /// Reuses an existing saved profile for the SSID when one exists (updating its /// PSK) so that repeated connections do not accumulate numbered duplicates in -/// NetworkManager ("NCC", "NCC 1", "NCC 2", …). Falls back to -/// `nmcli device wifi connect` — which creates a new profile — only when no -/// saved profile is found. +/// NetworkManager. Falls back to creating a new connection +/// (`AddAndActivateConnection2`, which also activates it) only when no saved +/// profile is found. /// /// `net.password` is only sent when `Some`: on the reuse path, `None` means -/// "leave the saved PSK alone" (either NetworkManager already durably owns -/// it, or the network is open); on the create path it means "no password -/// argument at all", which is also how a genuinely open (no-security) SSID -/// is connected. See the field doc on [`NetworkDef::password`] for how a -/// local secret transitions to `None` after its first successful use. +/// \"leave the saved secret alone\" (either NetworkManager already durably +/// owns it, or the network is open); on the create path it means \"no secret +/// section\", which is also how a genuinely open (no-security) SSID is +/// connected. See the field doc on [`NetworkDef::password`] for how a local +/// secret transitions to `None` after its first successful use. /// -/// KNOWN LIMITATION (credential exposure): when a password *is* sent, it's -/// passed to `nmcli` as a plain command-line argument -/// (`802-11-wireless-security.psk ` on the reuse path, `password ` -/// on the create path). For the lifetime of that `nmcli` child, the secret -/// is readable by other local users via `/proc//cmdline`. -/// `util::run_with_stdin` exists to feed secrets on stdin instead, but -/// wiring it up correctly needs either verified `nmcli --ask` piped-stdin -/// behavior or NetworkManager's D-Bus secret-agent API — neither of which -/// can be validated without a live NetworkManager connection — so this is -/// left as documented tech debt rather than a guess. In practice this -/// exposure window now only exists on a network's *first* connect: once -/// NetworkManager has the credential, breadcrumbs clears its local copy, so -/// there's nothing left to pass on argv for every subsequent connect. +/// The secret (when sent) travels inside the D-Bus `Update2`/`AddAndActivate +/// Connection2` settings payload — never on a process argv line — so it is +/// not readable by other local users via `/proc//cmdline` the way the +/// old `nmcli ... psk ` invocation was. pub fn connect_verbose(iface: &str, net: &NetworkDef, wait: u32, dns: &str) -> Result<(), String> { - let wait_s = wait.to_string(); + let conn = connection().ok_or_else(|| "cannot connect to the D-Bus system bus".to_string())?; + let dev = device_path(&conn, iface) + .ok_or_else(|| format!("no NetworkManager device named '{iface}'"))?; - if let Some(profile) = first_profile_for_ssid(&net.ssid) { + if let Some(profile) = first_profile_for_ssid(&conn, &net.ssid) { // Update the saved credentials and, for hidden networks, ensure the - // flag is set. PSK vs 802.1x (enterprise) profiles are updated with - // their own property sets. - if let Some(pw) = &net.password { - if net.eap.is_some() { - enterprise_modify(&profile, net); - } else { - let _ = run( - "nmcli", - &[ - "connection", - "modify", - &profile, - "802-11-wireless-security.psk", - pw.as_str(), - ], - Duration::from_secs(6), - ); - } - } - if net.hidden { - let _ = run( - "nmcli", - &[ - "connection", - "modify", - &profile, - "802-11-wireless.hidden", - "yes", - ], - Duration::from_secs(6), - ); - } - let o = run( - "nmcli", - &["--wait", &wait_s, "connection", "up", &profile, "ifname", iface], - Duration::from_secs(wait as u64 + 15), - ); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); - } - if let Some(uuid) = active_uuid(iface) { - enforce_dns(&uuid, iface, dns); + // hidden flag is set. PSK vs 802.1x (enterprise) profiles get their + // own property sets. + if net.password.is_some() || net.hidden { + let mut s = get_settings(&conn, profile.as_str()) + .ok_or_else(|| "failed to read saved profile settings".to_string())?; + updated_settings_for(net, &mut s); + // Update the existing profile in place via `Settings.Connection + // .Update2`. `Settings.AddConnection2` with the profile's own + // UUID would be rejected by real NetworkManager + // (NM_SETTINGS_ERROR_UUID_EXISTS — duplicates are not upserted). + let conn_proxy = proxy(&conn, profile.as_str(), CONN_IFACE) + .ok_or_else(|| "NetworkManager Settings.Connection unavailable".to_string())?; + let _: HashMap = conn_proxy + .call("Update2", &(&s, FLAG_TO_DISK, &HashMap::::new())) + .map_err(err_text)?; } + let nm = nm_proxy(&conn).ok_or_else(|| "NetworkManager unavailable".to_string())?; + let specific = zbus::zvariant::ObjectPath::try_from("/").expect("root object path"); + let _: OwnedObjectPath = nm + .call("ActivateConnection", &(&profile, &dev, &specific)) + .map_err(err_text)?; + wait_activated(&conn, &dev, wait)?; + enforce_dns(&conn, iface, dns); return Ok(()); } - if net.eap.is_some() { - // Enterprise networks can't be created via `device wifi connect` - // (no 802-1x options) — create the profile explicitly, then bring - // it up. - let args = enterprise_create_args(net, &wait_s, iface); - let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); - } - let o = run( - "nmcli", - &["--wait", &wait_s, "connection", "up", &net.ssid, "ifname", iface], - Duration::from_secs(wait as u64 + 15), - ); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); - } - if let Some(uuid) = active_uuid(iface) { - enforce_dns(&uuid, iface, dns); - } - return Ok(()); - } - - // No saved profile — create one via device wifi connect. - let hidden = if net.hidden { "yes" } else { "no" }; - let mut args: Vec<&str> = vec![ - "--wait", - &wait_s, - "device", - "wifi", - "connect", - net.ssid.as_str(), - ]; - // Only pass `password` when we actually have one. An empty/missing PSK - // argument makes nmcli treat the network as open (no security), which is - // what we want both for genuinely open SSIDs and for a network whose - // secret NetworkManager should already hold — though the latter case - // only succeeds if a saved profile in fact exists, which is why we only - // reach this branch (no saved profile found) when that assumption held. - if let Some(pw) = &net.password { - args.push("password"); - args.push(pw.as_str()); - } - args.push("hidden"); - args.push(hidden); - args.push("ifname"); - args.push(iface); - let o = run("nmcli", &args, Duration::from_secs(wait as u64 + 15)); - if !o.success { - let detail = o.stderr.trim().to_string(); - return Err(if detail.is_empty() { - o.stdout.trim().to_string() - } else { - detail - }); - } - if let Some(uuid) = active_uuid(iface) { - enforce_dns(&uuid, iface, dns); - } + // No saved profile — create one (and activate it in one call). + let settings = wifi_settings(net, &new_uuid()); + let nm = nm_proxy(&conn).ok_or_else(|| "NetworkManager unavailable".to_string())?; + let options: HashMap = + HashMap::from([("persist".to_string(), Value::from("disk"))]); + let specific = zbus::zvariant::ObjectPath::try_from("/").expect("root object path"); + let (_path, _active): (OwnedObjectPath, OwnedObjectPath) = nm + .call( + "AddAndActivateConnection2", + &(&settings, &dev, &specific, &options), + ) + .map_err(err_text)?; + wait_activated(&conn, &dev, wait)?; + enforce_dns(&conn, iface, dns); Ok(()) } -/// `nmcli connection modify` args switching an existing profile to the -/// network's 802.1x settings (`wifi-sec.key-mgmt wpa-eap` + 802-1x props). -fn enterprise_modify(profile: &str, net: &NetworkDef) { - let mut args: Vec<&str> = vec![ - "connection", - "modify", - profile, - "wifi-sec.key-mgmt", - "wpa-eap", - ]; - enterprise_props(&mut args, net); - let _ = run("nmcli", &args, Duration::from_secs(6)); -} - -/// Append the 802.1x property pairs for `net` to `args`. -fn enterprise_props<'a>(args: &mut Vec<&'a str>, net: &'a NetworkDef) { - if let Some(eap) = &net.eap { - args.push("802-1x.eap"); - args.push(eap.as_str()); - } - if let Some(id) = &net.identity { - args.push("802-1x.identity"); - args.push(id.as_str()); - } - if let Some(ca) = &net.ca_cert { - args.push("802-1x.ca-cert"); - args.push(ca.as_str()); - } - if let Some(pw) = &net.password { - args.push("802-1x.password"); - args.push(pw.as_str()); - } -} - -/// `nmcli connection add` args for an enterprise (802.1x) network — the -/// create path, since `device wifi connect` can't express 802-1x settings. -fn enterprise_create_args<'a>(net: &'a NetworkDef, wait_s: &'a str, iface: &'a str) -> Vec<&'a str> { - let mut args: Vec<&str> = vec![ - "--wait", - wait_s, - "connection", - "add", - "type", - "wifi", - "con-name", - net.ssid.as_str(), - "ssid", - net.ssid.as_str(), - "wifi-sec.key-mgmt", - "wpa-eap", - ]; - enterprise_props(&mut args, net); - if net.hidden { - args.push("802-11-wireless.hidden"); - args.push("yes"); - } - args.push("ifname"); - args.push(iface); - args -} - /// List all wireless connection profiles as `(name, ssid)` pairs, using the /// profile's `802-11-wireless.ssid` setting when present (falling back to /// the profile name). Used by `breadcrumbs prune`. pub fn wireless_profiles() -> Vec<(String, String)> { - let list = run( - "nmcli", - &["-t", "-f", "NAME,TYPE", "connection", "show"], - Duration::from_secs(8), - ); + let Some(conn) = connection() else { + return Vec::new(); + }; + let Some(settings) = proxy(&conn, SETTINGS_PATH, SETTINGS_IFACE) else { + return Vec::new(); + }; + let conns: Vec = match settings.call("ListConnections", &()) { + Ok(v) => v, + Err(_) => return Vec::new(), + }; let mut out = Vec::new(); - if !list.success { - return out; - } - for line in list.stdout.lines() { - let fields = split_fields(line); - if fields.len() < 2 || !fields[1].contains("wireless") { + for c in conns { + let Some(s) = get_settings(&conn, c.as_str()) else { + continue; + }; + let typ = s + .get("connection") + .and_then(|m| m.get("type")) + .and_then(|v| v.downcast_ref::().ok()); + if typ.as_deref() != Some("802-11-wireless") { continue; } - let name = fields[0].clone(); - let conn_ssid = run( - "nmcli", - &["-g", "802-11-wireless.ssid", "connection", "show", &name], - Duration::from_secs(6), - ); - let conn_ssid = conn_ssid.stdout.trim().to_string(); - out.push((name.clone(), if conn_ssid.is_empty() { name } else { conn_ssid })); + let name = s + .get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()) + .unwrap_or_default(); + let conn_ssid = s + .get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(|v| value_bytes(v)) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .filter(|s| !s.is_empty()); + out.push((name.clone(), conn_ssid.unwrap_or(name))); } out } @@ -611,39 +744,38 @@ pub fn wireless_profiles() -> Vec<(String, String)> { /// Delete every saved connection profile whose name or 802-11-wireless SSID /// matches `ssid` (used by `breadcrumbs forget` to purge stale entries). pub fn delete_connections_for_ssid(ssid: &str) -> bool { - let list = run( - "nmcli", - &["-t", "-f", "NAME,TYPE", "connection", "show"], - Duration::from_secs(8), - ); - if !list.success { + let Some(conn) = connection() else { return false; - } + }; + let Some(settings) = proxy(&conn, SETTINGS_PATH, SETTINGS_IFACE) else { + return false; + }; + let conns: Vec = match settings.call("ListConnections", &()) { + Ok(v) => v, + Err(_) => return false, + }; let mut removed = false; - for line in list.stdout.lines() { - let fields = split_fields(line); - if fields.len() < 2 { + for c in conns { + let Some(s) = get_settings(&conn, c.as_str()) else { continue; - } - let name = fields[0].clone(); - let typ = &fields[1]; - if !typ.contains("wireless") { - continue; - } - let conn_ssid = run( - "nmcli", - &["-g", "802-11-wireless.ssid", "connection", "show", &name], - Duration::from_secs(6), - ); - let conn_ssid = conn_ssid.stdout.trim(); - if (name == ssid || conn_ssid == ssid) - && run_ok( - "nmcli", - &["connection", "delete", "id", &name], - Duration::from_secs(8), - ) - { - removed = true; + }; + let name = s + .get("connection") + .and_then(|m| m.get("id")) + .and_then(|v| v.downcast_ref::().ok()) + .unwrap_or_default(); + let conn_ssid = s + .get("802-11-wireless") + .and_then(|m| m.get("ssid")) + .and_then(|v| value_bytes(v)) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + .unwrap_or_default(); + if name == ssid || conn_ssid == ssid { + if let Some(p) = proxy(&conn, c.as_str(), CONN_IFACE) { + if p.call::<_, _, ()>("Delete", &()).is_ok() { + removed = true; + } + } } } removed @@ -654,45 +786,41 @@ mod tests { use super::*; #[test] - fn unescape_handles_nmcli_escaping() { - assert_eq!(unescape("plain"), "plain"); - assert_eq!(unescape(r"a\:b"), "a:b"); - assert_eq!(unescape(r"back\\slash"), r"back\slash"); - assert_eq!(unescape("trailing\\"), "trailing\\"); + fn security_string_derivation() { + // Open AP: no privacy bit, no WPA/RSN. + assert_eq!(security_string(0, 0, 0), "--"); + // WPA2-PSK (RSN PSK set). + assert_eq!(security_string(AP_FLAG_PRIVACY, 0, SEC_PSK), "WPA2"); + // WPA1+WPA2 (both flag sets carry PSK). + assert_eq!(security_string(AP_FLAG_PRIVACY, SEC_PSK, SEC_PSK), "WPA1 WPA2"); + // WPA3 (SAE). + assert_eq!(security_string(AP_FLAG_PRIVACY, 0, SEC_SAE), "WPA3"); + // Enterprise (802.1x key management). + assert_eq!( + security_string(AP_FLAG_PRIVACY, SEC_802_1X, SEC_802_1X), + "802.1X" + ); + // WEP: privacy bit but no WPA/RSN. + assert_eq!(security_string(AP_FLAG_PRIVACY, 0, 0), "WEP"); } #[test] - fn split_fields_splits_and_unescapes() { - // SSID:SIGNAL:SECURITY with an escaped ':' inside the SSID. - let f = split_fields(r"My\:Net:72:WPA2"); - assert_eq!(f, vec!["My:Net", "72", "WPA2"]); - - // SSID with a space (common in real network names) - let f = split_fields("My Network:88:WPA2"); - assert_eq!(f, vec!["My Network", "88", "WPA2"]); - - // Empty SSID (hidden) keeps the empty leading field. - let f = split_fields(":40:WPA3"); - assert_eq!(f, vec!["", "40", "WPA3"]); + fn signal_rank_handles_percent_suffix() { + assert_eq!(signal_rank("90 %"), 90); + assert_eq!(signal_rank("80"), 80); + assert_eq!(signal_rank("garbage"), -100); } #[test] - fn split_fields_two_column_with_colon_in_first_field() { - // A connection NAME or SSID containing a literal ':' must not be - // mis-split into TYPE — this is what a plain `splitn(2, ':')` gets - // wrong (e.g. `wifi_interface`/`first_profile_for_ssid` parsing). - let f = split_fields(r"Office\:5G:802-11-wireless"); - assert_eq!(f, vec!["Office:5G", "802-11-wireless"]); - } - - #[test] - fn split_fields_empty_line() { - assert_eq!(split_fields(""), vec![""]); - } - - #[test] - fn split_fields_trailing_backslash_in_field() { - let f = split_fields(r"trail\\:wifi"); - assert_eq!(f, vec![r"trail\", "wifi"]); + fn uuid_v4_is_shape_valid() { + let u = new_uuid(); + let bytes = u.as_bytes(); + assert_eq!(bytes.len(), 36); + assert_eq!(bytes[8], b'-'); + assert_eq!(bytes[13], b'-'); + assert_eq!(bytes[18], b'-'); + assert_eq!(bytes[23], b'-'); + // Version nibble is 4. + assert_eq!(bytes[14], b'4'); } } diff --git a/src/status.rs b/src/status.rs index 8e0fe71..84b50e3 100644 --- a/src/status.rs +++ b/src/status.rs @@ -65,23 +65,6 @@ pub fn internet_ok(cfg: &Config) -> bool { matches!(connectivity(cfg), Connectivity::Online) } -fn ipv4(iface: &str) -> Option { - let o = run( - "nmcli", - &["-g", "IP4.ADDRESS", "device", "show", iface], - Duration::from_secs(6), - ); - if !o.success { - return None; - } - let s = o.stdout.trim(); - if s.is_empty() { - None - } else { - Some(s.lines().next().unwrap_or(s).trim().to_string()) - } -} - pub struct Status { pub iface: Option, pub ssid: Option, @@ -97,7 +80,7 @@ pub struct Status { pub fn gather(cfg: &Config, profile_name: &str) -> Status { let iface = nm::wifi_interface_preferred(cfg.settings.interface.as_deref()); let ssid = iface.as_deref().and_then(nm::active_ssid); - let ip = iface.as_deref().and_then(ipv4); + let ip = iface.as_deref().and_then(nm::ipv4_address); // Skip the (potentially 4s-blocking) connectivity probe when there's no // Wi-Fi interface at all: the watch loop classifies NoAdapter and would // otherwise burn a network round-trip (curl/ping) every tick for nothing. diff --git a/src/util.rs b/src/util.rs index 2a5ed41..3e8fced 100644 --- a/src/util.rs +++ b/src/util.rs @@ -88,14 +88,14 @@ pub fn command_exists(name: &str) -> bool { } /// Run a command with a hard timeout. The child is killed if it overruns so a -/// hung nmcli/tailscale can never wedge the daemon. +/// hung subprocess can never wedge the daemon. pub fn run(prog: &str, args: &[&str], timeout: Duration) -> Output { run_with_stdin(prog, args, None, timeout) } -/// Like [`run`], but feeds `stdin` to the child's standard input. Used to hand -/// secrets (e.g. Wi-Fi PSKs) to `nmcli --ask` without exposing them in argv, -/// where any local user could read them via `ps`. +/// Like [`run`], but feeds `stdin` to the child's standard input. +/// (Wi-Fi secrets no longer go through here: `nm` sends them inside D-Bus +/// payloads, never on a command line.) pub fn run_with_stdin(prog: &str, args: &[&str], stdin: Option<&str>, timeout: Duration) -> Output { RUNNER.with(|r| r.borrow().run(prog, args, stdin, timeout)) } diff --git a/src/watch.rs b/src/watch.rs index 3d64182..6da41b7 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -1,19 +1,27 @@ -use std::io::{BufRead, BufReader}; -use std::process::{Command, Stdio}; +use std::collections::HashMap; use std::sync::mpsc::{self, Receiver}; use std::thread; use std::time::{Duration, Instant}; use bread_utils::bread_client::BreadClient; +use zbus::blocking::{Connection, Proxy}; +use zbus::zvariant::OwnedValue; use crate::bread_events; use crate::config::Config; use crate::flow; +use crate::nm; use crate::notify::{log, notify, Urgency}; use crate::state::{self, State}; use crate::status::{self}; use crate::tailscale::TsHealth; +const NM_DEST: &str = "org.freedesktop.NetworkManager"; +const NM_PATH: &str = "/org/freedesktop/NetworkManager"; +const NM_IFACE: &str = "org.freedesktop.NetworkManager"; +const DEV_IFACE: &str = "org.freedesktop.NetworkManager.Device"; +const PROPS_IFACE: &str = "org.freedesktop.DBus.Properties"; + /// Coarse health classification the watch loop reacts to each tick. `pub` /// (and so is [`classify`]) purely so integration tests can drive the real /// classification logic in-process against a faked [`crate::util::Runner`], @@ -146,58 +154,89 @@ enum Wake { SetProfile(String), } -/// Tail `nmcli monitor` and ping the channel on link-state churn so we react -/// to drops within a second instead of waiting out the poll interval. -fn spawn_nm_monitor(tx: mpsc::Sender) { +/// Whether a `PropertiesChanged` message on the NM root changes the +/// `Connectivity` property of the NetworkManager interface — the signal +/// that catches "still connected but lost the internet" (portal, DHCP +/// failure) without waiting out the poll interval. +fn props_changed_connectivity(msg: &zbus::Message) -> bool { + let Ok(body) = msg.body().deserialize::<(String, HashMap, Vec)>() else { + return false; + }; + body.0 == NM_IFACE && body.1.contains_key("Connectivity") +} + +/// Subscribe to a D-Bus signal from NetworkManager and ping the channel for +/// each matching message, reconnecting on bus/NM restarts. One thread per +/// subscription (a handful at most); each owns its own connection so a dead +/// bus can't wedge the others. +fn spawn_signal_watcher(tx: mpsc::Sender, path: String, iface: &'static str, signal: &'static str, mut on_msg: F) +where + F: FnMut(&zbus::Message) -> bool + Send + 'static, +{ thread::spawn(move || loop { - let child = Command::new("nmcli") - .arg("monitor") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn(); - let mut child = match child { - Ok(c) => c, - Err(_) => { - thread::sleep(Duration::from_secs(10)); - continue; - } + let Ok(conn) = Connection::system() else { + thread::sleep(Duration::from_secs(10)); + continue; }; - if let Some(out) = child.stdout.take() { - let reader = BufReader::new(out); - // `None` means "haven't fired yet, so fire on the first interesting - // line". Storing an `Option` instead of seeding with - // `Instant::now() - 10s` avoids a panic: `Instant - Duration` - // underflows (and panics) when the monotonic clock is younger than - // the offset, which happens if `watch` starts within ~10s of boot — - // exactly when the systemd unit (ordered after graphical-session) - // tends to launch. - let mut last: Option = None; - for line in reader.lines().map_while(Result::ok) { - let l = line.to_lowercase(); - // `connectivity` lines catch drops that keep the device - // "connected" but lose the internet (captive portal, DHCP - // failure); `deactivating` covers teardown. Everything else - // waits out the poll interval. - let interesting = l.contains("disconnect") - || l.contains("unavailable") - || l.contains("failed") - || l.contains("deactivating") - || l.contains("connectivity"); - if interesting && debounce_ready(last, Duration::from_millis(1500)) { - last = Some(Instant::now()); - let _ = tx.send(Wake::LinkChurn); - } + let Ok(proxy) = Proxy::new(&conn, NM_DEST, path.as_str(), iface) else { + thread::sleep(Duration::from_secs(10)); + continue; + }; + let Ok(mut iter) = proxy.receive_signal(signal) else { + thread::sleep(Duration::from_secs(10)); + continue; + }; + // `None` means "haven't fired yet, so fire on the first interesting + // signal". Storing an `Option` instead of seeding with + // `Instant::now() - 10s` avoids a panic: `Instant - Duration` + // underflows (and panics) when the monotonic clock is younger than + // the offset, which happens if `watch` starts within ~10s of boot — + // exactly when the systemd unit (ordered after graphical-session) + // tends to launch. + let mut last: Option = None; + for msg in iter.by_ref() { + if on_msg(&msg) && debounce_ready(last, Duration::from_millis(1500)) { + last = Some(Instant::now()); + let _ = tx.send(Wake::LinkChurn); } } - let _ = child.wait(); - // monitor died (NM restart?) — back off and respawn. + // Subscription died (NM or bus restart) — back off and resubscribe. thread::sleep(Duration::from_secs(5)); }); } -/// Sleep up to `dur`, but wake early if `nmcli monitor` signals link churn or -/// a `set_profile` command arrives. Returns the pending action, if any. +/// Subscribe to NetworkManager D-Bus signals and ping the channel on +/// link-state churn so we react to drops within a second instead of waiting +/// out the poll interval. Replaces the old `nmcli monitor` subprocess: the +/// same events are observed, but as structured D-Bus signals. +/// +/// Watched signals: +/// - `PropertiesChanged` on the NM root object, filtered to the +/// `Connectivity` property — catches captive portals / DHCP failures that +/// keep the device "connected" while losing the internet; +/// - `DeviceAdded` / `DeviceRemoved` — hotplug; +/// - `Device.StateChanged` on every Wi-Fi device — drops and reconnects. +fn spawn_nm_monitor(tx: mpsc::Sender) { + spawn_signal_watcher( + tx.clone(), + NM_PATH.to_string(), + PROPS_IFACE, + "PropertiesChanged", + props_changed_connectivity, + ); + spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceAdded", |_| true); + spawn_signal_watcher(tx.clone(), NM_PATH.to_string(), NM_IFACE, "DeviceRemoved", |_| true); + // StateChanged on each Wi-Fi device. Devices added later (USB dongle + // hotplug) are caught by the DeviceAdded watcher waking the loop; the + // poll interval covers anything else. + for path in nm::wifi_device_paths() { + spawn_signal_watcher(tx.clone(), path, DEV_IFACE, "StateChanged", |_| true); + } +} + +/// Sleep up to `dur`, but wake early if the D-Bus signal monitor signals +/// link churn or a `set_profile` command arrives. Returns the pending +/// action, if any. fn wait_for_tick(rx: &Receiver, dur: Duration) -> Option { match rx.recv_timeout(dur) { Ok(first) => { @@ -301,10 +340,10 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { } profile = State::load(&cfg.settings.default_profile).profile; - // Suspend/resume: `nmcli monitor` sees nothing while the machine - // sleeps, so a large wall-clock gap means the network state may have - // changed underneath us — allow an immediate recovery run instead of - // waiting out any remaining flow cooldown. + // Suspend/resume: the D-Bus signal monitor sees nothing while the + // machine sleeps, so a large wall-clock gap means the network state + // may have changed underneath us — allow an immediate recovery run + // instead of waiting out any remaining flow cooldown. if last_tick_at.elapsed() > prev_wait + RESUME_SLACK { log("watch: large gap since last tick (suspend/resume?) — forcing recovery check"); last_flow_at = None;