From 266685628e61871438326a9b0ce92a3c16b0a8b0 Mon Sep 17 00:00:00 2001 From: Albert Armea Date: Tue, 6 Jan 2026 19:18:48 -0500 Subject: [PATCH] WIP: network check --- Cargo.lock | 886 +++++++++++++++++- Cargo.toml | 6 + config.example.toml | 24 + crates/shepherd-api/src/commands.rs | 1 + crates/shepherd-api/src/events.rs | 8 + crates/shepherd-api/src/types.rs | 19 + crates/shepherd-config/src/policy.rs | 81 +- crates/shepherd-config/src/schema.rs | 38 + crates/shepherd-config/src/validation.rs | 2 + crates/shepherd-core/src/engine.rs | 10 +- crates/shepherd-host-linux/Cargo.toml | 5 + .../shepherd-host-linux/src/connectivity.rs | 497 ++++++++++ crates/shepherd-host-linux/src/lib.rs | 3 + crates/shepherd-launcher-ui/src/client.rs | 1 + crates/shepherd-launcher-ui/src/state.rs | 4 + crates/shepherdd/Cargo.toml | 1 + crates/shepherdd/src/main.rs | 153 ++- crates/shepherdd/tests/integration.rs | 4 +- 18 files changed, 1710 insertions(+), 33 deletions(-) create mode 100644 crates/shepherd-host-linux/src/connectivity.rs diff --git a/Cargo.lock b/Cargo.lock index aa53fc9..7925741 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,12 +99,24 @@ dependencies = [ "syn", ] +[[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.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.10.0" @@ -117,6 +129,12 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.0" @@ -265,6 +283,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -315,6 +344,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645cbb3a84e60b7531617d5ae4e57f7e27308f6445f5abf653209ea76dec8dff" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -443,8 +481,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -454,9 +494,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -728,6 +770,107 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -752,6 +895,108 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -762,6 +1007,22 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -829,6 +1090,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.14" @@ -844,6 +1111,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -879,6 +1152,55 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "netlink-packet-core" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72724faf704479d67b388da142b186f916188505e7e0b26719019c525882eda4" +dependencies = [ + "anyhow", + "byteorder", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-route" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "483325d4bfef65699214858f097d504eb812c38ce7077d165f301ec406c3066e" +dependencies = [ + "anyhow", + "bitflags", + "byteorder", + "libc", + "log", + "netlink-packet-core", + "netlink-packet-utils", +] + +[[package]] +name = "netlink-packet-utils" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ede8a08c71ad5a95cdd0e4e52facd37190977039a4704eb82a283f713747d34" +dependencies = [ + "anyhow", + "byteorder", + "paste", + "thiserror 1.0.69", +] + +[[package]] +name = "netlink-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +dependencies = [ + "bytes", + "libc", + "log", +] + [[package]] name = "nix" version = "0.29.0" @@ -975,6 +1297,18 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -993,6 +1327,24 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[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.4.0" @@ -1011,6 +1363,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.42" @@ -1026,6 +1433,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1043,7 +1479,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1063,6 +1499,58 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -1077,6 +1565,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -1099,12 +1593,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" + [[package]] name = "scopeguard" version = "1.2.0" @@ -1178,6 +1713,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1201,7 +1748,7 @@ dependencies = [ "serde", "serde_json", "shepherd-util", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -1214,7 +1761,7 @@ dependencies = [ "shepherd-api", "shepherd-util", "tempfile", - "thiserror", + "thiserror 1.0.69", "toml 0.8.23", "tracing", ] @@ -1232,7 +1779,7 @@ dependencies = [ "shepherd-store", "shepherd-util", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -1246,7 +1793,7 @@ dependencies = [ "serde_json", "shepherd-api", "shepherd-util", - "thiserror", + "thiserror 1.0.69", "tokio", ] @@ -1255,15 +1802,20 @@ name = "shepherd-host-linux" version = "0.1.0" dependencies = [ "async-trait", + "chrono", "dirs", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", "nix", + "reqwest", "serde", "shell-escape", "shepherd-api", "shepherd-host-api", "shepherd-util", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -1297,7 +1849,7 @@ dependencies = [ "shepherd-api", "shepherd-util", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -1332,7 +1884,7 @@ dependencies = [ "shepherd-api", "shepherd-util", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", ] @@ -1344,7 +1896,7 @@ dependencies = [ "chrono", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "uuid", @@ -1357,6 +1909,7 @@ dependencies = [ "anyhow", "chrono", "clap", + "nix", "serde", "serde_json", "shepherd-api", @@ -1368,7 +1921,7 @@ dependencies = [ "shepherd-store", "shepherd-util", "tempfile", - "thiserror", + "thiserror 1.0.69", "tokio", "tracing", "tracing-subscriber", @@ -1412,12 +1965,24 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.111" @@ -1429,6 +1994,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-deps" version = "7.0.7" @@ -1467,7 +2052,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", ] [[package]] @@ -1481,6 +2075,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -1490,6 +2095,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.48.0" @@ -1518,6 +2148,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -1610,6 +2250,51 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1684,12 +2369,42 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1732,6 +2447,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1760,6 +2484,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.106" @@ -1792,6 +2529,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -1860,6 +2626,15 @@ dependencies = [ "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" @@ -2088,12 +2863,41 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + [[package]] name = "xml-rs" version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.31" @@ -2114,6 +2918,66 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index acea57a..89de2a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,8 +58,14 @@ anyhow = "1.0" uuid = { version = "1.6", features = ["v4", "serde"] } bitflags = "2.4" +# HTTP client (for connectivity checks) +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } + # Unix-specific nix = { version = "0.29", features = ["signal", "process", "user", "socket"] } +netlink-sys = "0.8" +netlink-packet-core = "0.7" +netlink-packet-route = "0.21" # CLI clap = { version = "4.5", features = ["derive", "env"] } diff --git a/config.example.toml b/config.example.toml index eded285..6454e11 100644 --- a/config.example.toml +++ b/config.example.toml @@ -30,6 +30,16 @@ max_volume = 80 # Maximum volume percentage (0-100) allow_mute = true # Whether mute toggle is allowed allow_change = true # Whether volume changes are allowed at all +# Network connectivity settings (optional) +# Used to check if Internet is available before allowing network-dependent entries +[service.network] +# URL to check for global network connectivity (default: Google's connectivity check) +# check_url = "http://connectivitycheck.gstatic.com/generate_204" +# How often to perform periodic connectivity checks, in seconds (default: 60) +# check_interval_seconds = 60 +# Timeout for connectivity checks, in seconds (default: 5) +# check_timeout_seconds = 5 + # Default warning thresholds [[service.default_warnings]] seconds_before = 300 @@ -211,6 +221,11 @@ message = "30 seconds! Save NOW!" [entries.volume] max_volume = 60 # Limit volume during gaming sessions +# Network requirements for online games +[entries.network] +required = true # Minecraft needs network for authentication and multiplayer +check_url = "http://www.msftconnecttest.com/connecttest.txt" # Use Microsoft's check (Minecraft is owned by Microsoft) + ## === Steam games === # Steam can be used via Canonical's Steam snap package: # https://snapcraft.io/steam @@ -244,6 +259,9 @@ end = "20:00" # No [entries.limits] section - uses service defaults # Omitting limits entirely uses default_max_run_seconds +[entries.network] +required = true # Steam needs network for authentication + # A Short Hike via Steam # https://store.steampowered.com/app/1055540/A_Short_Hike/ [[entries]] @@ -267,6 +285,9 @@ days = "weekends" start = "10:00" end = "20:00" +[entries.network] +required = true # Steam needs network for authentication + ## === Media === # Just use `mpv` to play media (for now). # Files can be local on your system or URLs (YouTube, etc). @@ -314,6 +335,9 @@ max_run_seconds = 0 # Unlimited: sleep/study aid daily_quota_seconds = 0 # Unlimited cooldown_seconds = 0 # No cooldown +[entries.network] +required = true # YouTube streaming needs network + # Terminal for debugging only [[entries]] id = "terminal" diff --git a/crates/shepherd-api/src/commands.rs b/crates/shepherd-api/src/commands.rs index 2e1ff91..58be40c 100644 --- a/crates/shepherd-api/src/commands.rs +++ b/crates/shepherd-api/src/commands.rs @@ -234,6 +234,7 @@ mod tests { current_session: None, entry_count: 5, entries: vec![], + connectivity: Default::default(), }), ); diff --git a/crates/shepherd-api/src/events.rs b/crates/shepherd-api/src/events.rs index 3222a0b..48bbe48 100644 --- a/crates/shepherd-api/src/events.rs +++ b/crates/shepherd-api/src/events.rs @@ -88,6 +88,14 @@ pub enum EventPayload { event_type: String, details: serde_json::Value, }, + + /// Network connectivity status changed + ConnectivityChanged { + /// Whether global connectivity check now passes + connected: bool, + /// The URL that was checked + check_url: String, + }, } #[cfg(test)] diff --git a/crates/shepherd-api/src/types.rs b/crates/shepherd-api/src/types.rs index 668d649..3f3dd65 100644 --- a/crates/shepherd-api/src/types.rs +++ b/crates/shepherd-api/src/types.rs @@ -121,6 +121,11 @@ pub enum ReasonCode { Disabled { reason: Option, }, + /// Network connectivity check failed + NetworkUnavailable { + /// The URL that was checked + check_url: String, + }, } /// Warning severity level @@ -197,6 +202,20 @@ pub struct ServiceStateSnapshot { /// Available entries for UI display #[serde(default)] pub entries: Vec, + /// Network connectivity status + #[serde(default)] + pub connectivity: ConnectivityStatus, +} + +/// Network connectivity status +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ConnectivityStatus { + /// Whether global network connectivity check passed + pub connected: bool, + /// The URL that was checked for global connectivity + pub check_url: Option, + /// When the last check was performed + pub last_check: Option>, } /// Role for authorization diff --git a/crates/shepherd-config/src/policy.rs b/crates/shepherd-config/src/policy.rs index 2389e52..0d4330b 100644 --- a/crates/shepherd-config/src/policy.rs +++ b/crates/shepherd-config/src/policy.rs @@ -1,6 +1,6 @@ //! Validated policy structures -use crate::schema::{RawConfig, RawEntry, RawEntryKind, RawVolumeConfig, RawServiceConfig, RawWarningThreshold}; +use crate::schema::{RawConfig, RawEntry, RawEntryKind, RawNetworkConfig, RawEntryNetwork, RawVolumeConfig, RawServiceConfig, RawWarningThreshold}; use crate::validation::{parse_days, parse_time}; use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold}; use shepherd_util::{DaysOfWeek, EntryId, TimeWindow, WallClock, default_data_dir, default_log_dir, socket_path_without_env}; @@ -24,6 +24,9 @@ pub struct Policy { /// Global volume restrictions pub volume: VolumePolicy, + + /// Network connectivity policy + pub network: NetworkPolicy, } impl Policy { @@ -50,6 +53,13 @@ impl Policy { .map(convert_volume_config) .unwrap_or_default(); + let network = raw + .service + .network + .as_ref() + .map(convert_network_config) + .unwrap_or_default(); + let entries = raw .entries .into_iter() @@ -62,6 +72,7 @@ impl Policy { default_warnings, default_max_run, volume: global_volume, + network, } } @@ -130,6 +141,7 @@ pub struct Entry { pub limits: LimitsPolicy, pub warnings: Vec, pub volume: Option, + pub network: NetworkRequirement, pub disabled: bool, pub disabled_reason: Option, } @@ -159,6 +171,11 @@ impl Entry { .map(|w| w.into_iter().map(convert_warning).collect()) .unwrap_or_else(|| default_warnings.to_vec()); let volume = raw.volume.as_ref().map(convert_volume_config); + let network = raw + .network + .as_ref() + .map(convert_entry_network) + .unwrap_or_default(); Self { id: EntryId::new(raw.id), @@ -169,6 +186,7 @@ impl Entry { limits, warnings, volume, + network, disabled: raw.disabled, disabled_reason: raw.disabled_reason, } @@ -250,6 +268,52 @@ impl VolumePolicy { } } +/// Default connectivity check URL (Google's connectivity check service) +pub const DEFAULT_CHECK_URL: &str = "http://connectivitycheck.gstatic.com/generate_204"; + +/// Default interval for periodic connectivity checks (60 seconds) +pub const DEFAULT_CHECK_INTERVAL_SECS: u64 = 60; + +/// Default timeout for connectivity checks (5 seconds) +pub const DEFAULT_CHECK_TIMEOUT_SECS: u64 = 5; + +/// Network connectivity policy +#[derive(Debug, Clone)] +pub struct NetworkPolicy { + /// URL to check for global network connectivity + pub check_url: String, + /// How often to perform periodic connectivity checks + pub check_interval: Duration, + /// Timeout for connectivity checks + pub check_timeout: Duration, +} + +impl Default for NetworkPolicy { + fn default() -> Self { + Self { + check_url: DEFAULT_CHECK_URL.to_string(), + check_interval: Duration::from_secs(DEFAULT_CHECK_INTERVAL_SECS), + check_timeout: Duration::from_secs(DEFAULT_CHECK_TIMEOUT_SECS), + } + } +} + +/// Network requirements for a specific entry +#[derive(Debug, Clone, Default)] +pub struct NetworkRequirement { + /// Whether this entry requires network connectivity to launch + pub required: bool, + /// Override check URL for this entry (uses global if None) + pub check_url_override: Option, +} + +impl NetworkRequirement { + /// Get the check URL to use for this entry, given the global policy + pub fn effective_check_url<'a>(&'a self, global: &'a NetworkPolicy) -> &'a str { + self.check_url_override.as_deref().unwrap_or(&global.check_url) + } +} + // Conversion helpers fn convert_entry_kind(raw: RawEntryKind) -> EntryKind { @@ -282,6 +346,21 @@ fn convert_volume_config(raw: &RawVolumeConfig) -> VolumePolicy { } } +fn convert_network_config(raw: &RawNetworkConfig) -> NetworkPolicy { + NetworkPolicy { + check_url: raw.check_url.clone().unwrap_or_else(|| DEFAULT_CHECK_URL.to_string()), + check_interval: Duration::from_secs(raw.check_interval_seconds.unwrap_or(DEFAULT_CHECK_INTERVAL_SECS)), + check_timeout: Duration::from_secs(raw.check_timeout_seconds.unwrap_or(DEFAULT_CHECK_TIMEOUT_SECS)), + } +} + +fn convert_entry_network(raw: &RawEntryNetwork) -> NetworkRequirement { + NetworkRequirement { + required: raw.required, + check_url_override: raw.check_url.clone(), + } +} + fn convert_time_window(raw: crate::schema::RawTimeWindow) -> TimeWindow { let days_mask = parse_days(&raw.days).unwrap_or(0x7F); let (start_h, start_m) = parse_time(&raw.start).unwrap_or((0, 0)); diff --git a/crates/shepherd-config/src/schema.rs b/crates/shepherd-config/src/schema.rs index d1b5370..fd147f1 100644 --- a/crates/shepherd-config/src/schema.rs +++ b/crates/shepherd-config/src/schema.rs @@ -48,6 +48,26 @@ pub struct RawServiceConfig { /// Global volume restrictions #[serde(default)] pub volume: Option, + + /// Network connectivity settings + #[serde(default)] + pub network: Option, +} + +/// Network connectivity configuration +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct RawNetworkConfig { + /// URL to check for global network connectivity + /// Default: "http://connectivitycheck.gstatic.com/generate_204" + pub check_url: Option, + + /// How often to perform periodic connectivity checks (in seconds) + /// Default: 30 + pub check_interval_seconds: Option, + + /// Timeout for connectivity checks (in seconds) + /// Default: 5 + pub check_timeout_seconds: Option, } /// Raw entry definition @@ -81,6 +101,10 @@ pub struct RawEntry { #[serde(default)] pub volume: Option, + /// Network requirements for this entry + #[serde(default)] + pub network: Option, + /// Explicitly disabled #[serde(default)] pub disabled: bool, @@ -89,6 +113,20 @@ pub struct RawEntry { pub disabled_reason: Option, } +/// Network requirements for an entry +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct RawEntryNetwork { + /// Whether this entry requires network connectivity to launch + /// If true, the entry will not be available if the network check fails + #[serde(default)] + pub required: bool, + + /// Override check URL for this entry + /// If specified, this URL will be checked instead of the global check_url + /// This is useful for entries that need specific services (e.g., Google, Microsoft) + pub check_url: Option, +} + /// Raw entry kind #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] diff --git a/crates/shepherd-config/src/validation.rs b/crates/shepherd-config/src/validation.rs index 5905f0c..f949987 100644 --- a/crates/shepherd-config/src/validation.rs +++ b/crates/shepherd-config/src/validation.rs @@ -260,6 +260,7 @@ mod tests { limits: None, warnings: None, volume: None, + network: None, disabled: false, disabled_reason: None, }, @@ -277,6 +278,7 @@ mod tests { limits: None, warnings: None, volume: None, + network: None, disabled: false, disabled_reason: None, }, diff --git a/crates/shepherd-core/src/engine.rs b/crates/shepherd-core/src/engine.rs index 1ad6f5d..fc0c774 100644 --- a/crates/shepherd-core/src/engine.rs +++ b/crates/shepherd-core/src/engine.rs @@ -506,6 +506,8 @@ impl CoreEngine { current_session, entry_count: self.policy.entries.len(), entries, + // Connectivity is populated by the daemon, not the core engine + connectivity: Default::default(), } } @@ -565,7 +567,7 @@ impl CoreEngine { #[cfg(test)] mod tests { use super::*; - use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy}; + use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy, NetworkRequirement}; use shepherd_api::EntryKind; use shepherd_store::SqliteStore; use std::collections::HashMap; @@ -594,12 +596,14 @@ mod tests { }, warnings: vec![], volume: None, + network: NetworkRequirement::default(), disabled: false, disabled_reason: None, }], default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), volume: Default::default(), + network: Default::default(), } } @@ -677,6 +681,7 @@ mod tests { message_template: Some("1 minute left".into()), }], volume: None, + network: NetworkRequirement::default(), disabled: false, disabled_reason: None, }], @@ -684,6 +689,7 @@ mod tests { default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), volume: Default::default(), + network: Default::default(), }; let store = Arc::new(SqliteStore::in_memory().unwrap()); @@ -742,6 +748,7 @@ mod tests { }, warnings: vec![], volume: None, + network: NetworkRequirement::default(), disabled: false, disabled_reason: None, }], @@ -749,6 +756,7 @@ mod tests { default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), volume: Default::default(), + network: Default::default(), }; let store = Arc::new(SqliteStore::in_memory().unwrap()); diff --git a/crates/shepherd-host-linux/Cargo.toml b/crates/shepherd-host-linux/Cargo.toml index 4459e39..ed7fa7b 100644 --- a/crates/shepherd-host-linux/Cargo.toml +++ b/crates/shepherd-host-linux/Cargo.toml @@ -17,6 +17,11 @@ nix = { workspace = true } async-trait = "0.1" dirs = "5.0" shell-escape = "0.1" +chrono = { workspace = true } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +netlink-sys = "0.8" +netlink-packet-core = "0.7" +netlink-packet-route = "0.21" [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/shepherd-host-linux/src/connectivity.rs b/crates/shepherd-host-linux/src/connectivity.rs new file mode 100644 index 0000000..55dadaa --- /dev/null +++ b/crates/shepherd-host-linux/src/connectivity.rs @@ -0,0 +1,497 @@ +//! Network connectivity monitoring for Linux +//! +//! This module provides: +//! - Periodic connectivity checks to a configurable URL +//! - Network interface change detection via netlink +//! - Per-entry connectivity status tracking + +#![allow(dead_code)] // Methods on ConnectivityMonitor may be used for future admin commands + +use chrono::{DateTime, Local}; +use netlink_packet_core::{NetlinkMessage, NetlinkPayload}; +use netlink_packet_route::RouteNetlinkMessage; +use netlink_sys::{protocols::NETLINK_ROUTE, Socket, SocketAddr}; +use reqwest::Client; +use std::collections::HashMap; +use std::os::fd::AsRawFd; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{mpsc, watch, RwLock}; +use tracing::{debug, error, info, warn}; + +/// Events emitted by the connectivity monitor +#[derive(Debug, Clone)] +pub enum ConnectivityEvent { + /// Global connectivity status changed + StatusChanged { + connected: bool, + check_url: String, + }, + /// Network interface changed (may trigger recheck) + InterfaceChanged, +} + +/// Configuration for the connectivity monitor +#[derive(Debug, Clone)] +pub struct ConnectivityConfig { + /// URL to check for global network connectivity + pub check_url: String, + /// How often to perform periodic connectivity checks + pub check_interval: Duration, + /// Timeout for connectivity checks + pub check_timeout: Duration, +} + +/// Cached connectivity check result +#[derive(Debug, Clone)] +struct CheckResult { + connected: bool, + checked_at: DateTime, +} + +/// Connectivity monitor that tracks network availability +pub struct ConnectivityMonitor { + /// HTTP client for connectivity checks + client: Client, + /// Configuration + config: ConnectivityConfig, + /// Current global connectivity status + global_status: Arc>>, + /// Cached results for specific URLs (entry-specific checks) + url_cache: Arc>>, + /// Channel for sending events + event_tx: mpsc::Sender, + /// Shutdown signal + shutdown_rx: watch::Receiver, +} + +impl ConnectivityMonitor { + /// Create a new connectivity monitor + pub fn new( + config: ConnectivityConfig, + shutdown_rx: watch::Receiver, + ) -> (Self, mpsc::Receiver) { + let (event_tx, event_rx) = mpsc::channel(32); + + let client = Client::builder() + .timeout(config.check_timeout) + .connect_timeout(config.check_timeout) + .build() + .expect("Failed to create HTTP client"); + + let monitor = Self { + client, + config, + global_status: Arc::new(RwLock::new(None)), + url_cache: Arc::new(RwLock::new(HashMap::new())), + event_tx, + shutdown_rx, + }; + + (monitor, event_rx) + } + + /// Start the connectivity monitor (runs until shutdown) + pub async fn run(self) { + let check_interval = self.config.check_interval; + let check_url = self.config.check_url.clone(); + + // Spawn periodic check task + let periodic_handle = { + let client = self.client.clone(); + let global_status = self.global_status.clone(); + let event_tx = self.event_tx.clone(); + let check_url = check_url.clone(); + let check_timeout = self.config.check_timeout; + let mut shutdown = self.shutdown_rx.clone(); + + tokio::spawn(async move { + let mut interval = tokio::time::interval(check_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + + // Do initial check immediately + let connected = check_url_reachable(&client, &check_url, check_timeout).await; + update_global_status(&global_status, &event_tx, &check_url, connected).await; + + loop { + tokio::select! { + _ = interval.tick() => { + let connected = check_url_reachable(&client, &check_url, check_timeout).await; + update_global_status(&global_status, &event_tx, &check_url, connected).await; + } + _ = shutdown.changed() => { + if *shutdown.borrow() { + debug!("Periodic check task shutting down"); + break; + } + } + } + } + }) + }; + + // Spawn netlink monitor task + let netlink_handle = { + let client = self.client.clone(); + let global_status = self.global_status.clone(); + let url_cache = self.url_cache.clone(); + let event_tx = self.event_tx.clone(); + let check_url = check_url.clone(); + let check_timeout = self.config.check_timeout; + let mut shutdown = self.shutdown_rx.clone(); + + tokio::spawn(async move { + if let Err(e) = run_netlink_monitor( + &client, + &global_status, + &url_cache, + &event_tx, + &check_url, + check_timeout, + &mut shutdown, + ) + .await + { + warn!(error = %e, "Netlink monitor failed, network change detection unavailable"); + } + }) + }; + + // Wait for shutdown + let mut shutdown = self.shutdown_rx.clone(); + let _ = shutdown.changed().await; + + // Cancel tasks + periodic_handle.abort(); + netlink_handle.abort(); + + info!("Connectivity monitor stopped"); + } + + /// Get the current global connectivity status + pub async fn is_connected(&self) -> bool { + self.global_status + .read() + .await + .as_ref() + .is_some_and(|r| r.connected) + } + + /// Get the last check time + pub async fn last_check_time(&self) -> Option> { + self.global_status.read().await.as_ref().map(|r| r.checked_at) + } + + /// Check if a specific URL is reachable (with caching) + /// Used for entry-specific network requirements + pub async fn check_url(&self, url: &str) -> bool { + // Check cache first + { + let cache = self.url_cache.read().await; + if let Some(result) = cache.get(url) { + // Cache valid for half the check interval + let cache_ttl = self.config.check_interval / 2; + let age = shepherd_util::now() + .signed_duration_since(result.checked_at) + .to_std() + .unwrap_or(Duration::MAX); + if age < cache_ttl { + return result.connected; + } + } + } + + // Perform check + let connected = check_url_reachable(&self.client, url, self.config.check_timeout).await; + + // Update cache + { + let mut cache = self.url_cache.write().await; + cache.insert( + url.to_string(), + CheckResult { + connected, + checked_at: shepherd_util::now(), + }, + ); + } + + connected + } + + /// Force an immediate connectivity recheck + pub async fn trigger_recheck(&self) { + let connected = + check_url_reachable(&self.client, &self.config.check_url, self.config.check_timeout) + .await; + update_global_status( + &self.global_status, + &self.event_tx, + &self.config.check_url, + connected, + ) + .await; + + // Clear URL cache to force rechecks + self.url_cache.write().await.clear(); + } +} + +/// Check if a URL is reachable +async fn check_url_reachable(client: &Client, url: &str, timeout: Duration) -> bool { + debug!(url = %url, "Checking connectivity"); + + match client + .get(url) + .timeout(timeout) + .send() + .await + { + Ok(response) => { + let status = response.status(); + let connected = status.is_success() || status.as_u16() == 204; + debug!(url = %url, status = %status, connected = connected, "Connectivity check complete"); + connected + } + Err(e) => { + debug!(url = %url, error = %e, "Connectivity check failed"); + false + } + } +} + +/// Update global status and emit event if changed +async fn update_global_status( + global_status: &Arc>>, + event_tx: &mpsc::Sender, + check_url: &str, + connected: bool, +) { + let mut status = global_status.write().await; + let previous = status.as_ref().map(|r| r.connected); + + *status = Some(CheckResult { + connected, + checked_at: shepherd_util::now(), + }); + + // Emit event if status changed + if previous != Some(connected) { + info!( + connected = connected, + url = %check_url, + "Global connectivity status changed" + ); + let _ = event_tx + .send(ConnectivityEvent::StatusChanged { + connected, + check_url: check_url.to_string(), + }) + .await; + } +} + +/// Run the netlink monitor to detect network interface changes +async fn run_netlink_monitor( + client: &Client, + global_status: &Arc>>, + url_cache: &Arc>>, + event_tx: &mpsc::Sender, + check_url: &str, + check_timeout: Duration, + shutdown: &mut watch::Receiver, +) -> Result<(), Box> { + // Create netlink socket for route notifications + let mut socket = Socket::new(NETLINK_ROUTE)?; + + // Bind to multicast groups for link and address changes + // RTMGRP_LINK = 1, RTMGRP_IPV4_IFADDR = 0x10, RTMGRP_IPV6_IFADDR = 0x100 + let groups = 1 | 0x10 | 0x100; + let addr = SocketAddr::new(0, groups); + socket.bind(&addr)?; + + // Set non-blocking for async compatibility + socket.set_non_blocking(true)?; + + info!("Netlink monitor started"); + + let fd = socket.as_raw_fd(); + let mut buf = vec![0u8; 4096]; + + loop { + // Use tokio's async fd for the socket + let async_fd = tokio::io::unix::AsyncFd::new(fd)?; + + tokio::select! { + result = async_fd.readable() => { + match result { + Ok(mut guard) => { + // Try to read from socket + match socket.recv(&mut buf, 0) { + Ok(len) if len > 0 => { + // Parse netlink messages + if has_relevant_netlink_event(&buf[..len]) { + debug!("Network interface change detected"); + let _ = event_tx.send(ConnectivityEvent::InterfaceChanged).await; + + // Clear URL cache + url_cache.write().await.clear(); + + // Recheck connectivity after a short delay + // (give network time to stabilize) + tokio::time::sleep(Duration::from_millis(500)).await; + + let connected = check_url_reachable(client, check_url, check_timeout).await; + update_global_status(global_status, event_tx, check_url, connected).await; + } + guard.clear_ready(); + } + Ok(_) => { + guard.clear_ready(); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + guard.clear_ready(); + } + Err(e) => { + error!(error = %e, "Netlink recv error"); + guard.clear_ready(); + } + } + } + Err(e) => { + error!(error = %e, "Async fd error"); + } + } + } + _ = shutdown.changed() => { + if *shutdown.borrow() { + debug!("Netlink monitor shutting down"); + break; + } + } + } + } + + Ok(()) +} + +/// Check if a netlink message buffer contains relevant network events +fn has_relevant_netlink_event(buf: &[u8]) -> bool { + let mut offset = 0; + + while offset < buf.len() { + match NetlinkMessage::::deserialize(&buf[offset..]) { + Ok(msg) => { + if let NetlinkPayload::InnerMessage(route_msg) = &msg.payload + && matches!( + route_msg, + // Link up/down events + RouteNetlinkMessage::NewLink(_) + | RouteNetlinkMessage::DelLink(_) + // Address added/removed + | RouteNetlinkMessage::NewAddress(_) + | RouteNetlinkMessage::DelAddress(_) + // Route changes + | RouteNetlinkMessage::NewRoute(_) + | RouteNetlinkMessage::DelRoute(_) + ) + { + return true; + } + + // Move to next message + let len = msg.header.length as usize; + if len == 0 { + break; + } + offset += len; + } + Err(_) => break, + } + } + + false +} + +/// Handle for accessing connectivity status from other parts of the service +#[derive(Clone)] +pub struct ConnectivityHandle { + client: Client, + global_status: Arc>>, + url_cache: Arc>>, + check_timeout: Duration, + cache_ttl: Duration, + global_check_url: String, +} + +impl ConnectivityHandle { + /// Create a handle from the monitor + pub fn from_monitor(monitor: &ConnectivityMonitor) -> Self { + Self { + client: monitor.client.clone(), + global_status: monitor.global_status.clone(), + url_cache: monitor.url_cache.clone(), + check_timeout: monitor.config.check_timeout, + cache_ttl: monitor.config.check_interval / 2, + global_check_url: monitor.config.check_url.clone(), + } + } + + /// Get the current global connectivity status + pub async fn is_connected(&self) -> bool { + self.global_status + .read() + .await + .as_ref() + .is_some_and(|r| r.connected) + } + + /// Get the last check time + pub async fn last_check_time(&self) -> Option> { + self.global_status.read().await.as_ref().map(|r| r.checked_at) + } + + /// Get the global check URL + pub fn global_check_url(&self) -> &str { + &self.global_check_url + } + + /// Check if a specific URL is reachable (with caching) + pub async fn check_url(&self, url: &str) -> bool { + // If it's the global URL, use global status + if url == self.global_check_url { + return self.is_connected().await; + } + + // Check cache first + { + let cache = self.url_cache.read().await; + if let Some(result) = cache.get(url) { + let age = shepherd_util::now() + .signed_duration_since(result.checked_at) + .to_std() + .unwrap_or(Duration::MAX); + if age < self.cache_ttl { + return result.connected; + } + } + } + + // Perform check + let connected = check_url_reachable(&self.client, url, self.check_timeout).await; + + // Update cache + { + let mut cache = self.url_cache.write().await; + cache.insert( + url.to_string(), + CheckResult { + connected, + checked_at: shepherd_util::now(), + }, + ); + } + + connected + } +} diff --git a/crates/shepherd-host-linux/src/lib.rs b/crates/shepherd-host-linux/src/lib.rs index 12b101c..3e65178 100644 --- a/crates/shepherd-host-linux/src/lib.rs +++ b/crates/shepherd-host-linux/src/lib.rs @@ -6,11 +6,14 @@ //! - Exit observation //! - stdout/stderr capture //! - Volume control with auto-detection of sound systems +//! - Network connectivity monitoring via netlink mod adapter; +mod connectivity; mod process; mod volume; pub use adapter::*; +pub use connectivity::*; pub use process::*; pub use volume::*; diff --git a/crates/shepherd-launcher-ui/src/client.rs b/crates/shepherd-launcher-ui/src/client.rs index d05f756..cbb7c27 100644 --- a/crates/shepherd-launcher-ui/src/client.rs +++ b/crates/shepherd-launcher-ui/src/client.rs @@ -252,5 +252,6 @@ fn reason_to_message(reason: &ReasonCode) -> &'static str { ReasonCode::SessionActive { .. } => "Another session is active", ReasonCode::UnsupportedKind { .. } => "Entry type not supported", ReasonCode::Disabled { .. } => "Entry disabled", + ReasonCode::NetworkUnavailable { .. } => "Network connection required", } } diff --git a/crates/shepherd-launcher-ui/src/state.rs b/crates/shepherd-launcher-ui/src/state.rs index 91f4bcd..7ef99ae 100644 --- a/crates/shepherd-launcher-ui/src/state.rs +++ b/crates/shepherd-launcher-ui/src/state.rs @@ -117,6 +117,10 @@ impl SharedState { EventPayload::VolumeChanged { .. } => { // Volume events are handled by HUD } + EventPayload::ConnectivityChanged { .. } => { + // Connectivity changes may affect entry availability - request fresh state + self.set(LauncherState::Connecting); + } } } diff --git a/crates/shepherdd/Cargo.toml b/crates/shepherdd/Cargo.toml index 5eafd0c..b18bd73 100644 --- a/crates/shepherdd/Cargo.toml +++ b/crates/shepherdd/Cargo.toml @@ -27,6 +27,7 @@ tracing-subscriber = { workspace = true } tokio = { workspace = true } anyhow = { workspace = true } clap = { version = "4.5", features = ["derive", "env"] } +nix = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/shepherdd/src/main.rs b/crates/shepherdd/src/main.rs index 9d93733..c09a194 100644 --- a/crates/shepherdd/src/main.rs +++ b/crates/shepherdd/src/main.rs @@ -8,17 +8,22 @@ //! - Host adapter (Linux) //! - IPC server //! - Volume control +//! - Network connectivity monitoring use anyhow::{Context, Result}; use clap::Parser; use shepherd_api::{ - Command, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus, - Response, ResponsePayload, SessionEndReason, StopMode, VolumeInfo, VolumeRestrictions, + Command, ConnectivityStatus, ErrorCode, ErrorInfo, Event, EventPayload, HealthStatus, + ReasonCode, Response, ResponsePayload, SessionEndReason, StopMode, VolumeInfo, + VolumeRestrictions, }; use shepherd_config::{load_config, VolumePolicy}; use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision, StopDecision}; use shepherd_host_api::{HostAdapter, HostEvent, StopMode as HostStopMode, VolumeController}; -use shepherd_host_linux::{LinuxHost, LinuxVolumeController}; +use shepherd_host_linux::{ + ConnectivityConfig, ConnectivityEvent, ConnectivityHandle, ConnectivityMonitor, LinuxHost, + LinuxVolumeController, +}; use shepherd_ipc::{IpcServer, ServerMessage}; use shepherd_store::{AuditEvent, AuditEventType, SqliteStore, Store}; use shepherd_util::{default_config_path, ClientId, MonotonicInstant, RateLimiter}; @@ -26,7 +31,7 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio::signal::unix::{signal, SignalKind}; -use tokio::sync::Mutex; +use tokio::sync::{mpsc, watch, Mutex}; use tracing::{debug, error, info, warn}; use tracing_subscriber::EnvFilter; @@ -60,10 +65,12 @@ struct Service { ipc: Arc, store: Arc, rate_limiter: RateLimiter, + connectivity: ConnectivityHandle, + shutdown_tx: watch::Sender, } impl Service { - async fn new(args: &Args) -> Result { + async fn new(args: &Args) -> Result<(Self, mpsc::Receiver)> { // Load configuration let policy = load_config(&args.config) .with_context(|| format!("Failed to load config from {:?}", args.config))?; @@ -116,6 +123,7 @@ impl Service { } // Initialize core engine + let network_policy = policy.network.clone(); let engine = CoreEngine::new(policy, store.clone(), host.capabilities().clone()); // Initialize IPC server @@ -127,17 +135,43 @@ impl Service { // Rate limiter: 30 requests per second per client let rate_limiter = RateLimiter::new(30, Duration::from_secs(1)); - Ok(Self { - engine, - host, - volume, - ipc: Arc::new(ipc), - store, - rate_limiter, - }) + // Initialize connectivity monitor + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let connectivity_config = ConnectivityConfig { + check_url: network_policy.check_url, + check_interval: network_policy.check_interval, + check_timeout: network_policy.check_timeout, + }; + let (connectivity_monitor, connectivity_events) = + ConnectivityMonitor::new(connectivity_config, shutdown_rx); + let connectivity = ConnectivityHandle::from_monitor(&connectivity_monitor); + + // Spawn connectivity monitor task + tokio::spawn(async move { + connectivity_monitor.run().await; + }); + + info!( + check_url = %connectivity.global_check_url(), + "Connectivity monitor started" + ); + + Ok(( + Self { + engine, + host, + volume, + ipc: Arc::new(ipc), + store, + rate_limiter, + connectivity, + shutdown_tx, + }, + connectivity_events, + )) } - async fn run(self) -> Result<()> { + async fn run(self, mut connectivity_events: mpsc::Receiver) -> Result<()> { // Start host process monitor let _monitor_handle = self.host.start_monitor(); @@ -155,6 +189,8 @@ impl Service { let host = self.host.clone(); let volume = self.volume.clone(); let store = self.store.clone(); + let connectivity = self.connectivity.clone(); + let shutdown_tx = self.shutdown_tx.clone(); // Spawn IPC accept task let ipc_accept = ipc_ref.clone(); @@ -218,7 +254,12 @@ impl Service { // IPC messages Some(msg) = ipc_messages.recv() => { - Self::handle_ipc_message(&engine, &host, &volume, &ipc_ref, &store, &rate_limiter, msg).await; + Self::handle_ipc_message(&engine, &host, &volume, &ipc_ref, &store, &rate_limiter, &connectivity, msg).await; + } + + // Connectivity events + Some(conn_event) = connectivity_events.recv() => { + Self::handle_connectivity_event(&engine, &ipc_ref, &connectivity, conn_event).await; } } } @@ -226,6 +267,9 @@ impl Service { // Graceful shutdown info!("Shutting down shepherdd"); + // Signal connectivity monitor to stop + let _ = shutdown_tx.send(true); + // Stop all running sessions { let engine = engine.lock().await; @@ -433,6 +477,45 @@ impl Service { } } + async fn handle_connectivity_event( + engine: &Arc>, + ipc: &Arc, + connectivity: &ConnectivityHandle, + event: ConnectivityEvent, + ) { + match event { + ConnectivityEvent::StatusChanged { + connected, + check_url, + } => { + info!(connected = connected, url = %check_url, "Connectivity status changed"); + + // Broadcast connectivity change event + ipc.broadcast_event(Event::new(EventPayload::ConnectivityChanged { + connected, + check_url, + })); + + // Also broadcast state change so clients can update entry availability + let state = { + let eng = engine.lock().await; + let mut state = eng.get_state(); + state.connectivity = ConnectivityStatus { + connected: connectivity.is_connected().await, + check_url: Some(connectivity.global_check_url().to_string()), + last_check: connectivity.last_check_time().await, + }; + state + }; + ipc.broadcast_event(Event::new(EventPayload::StateChanged(state))); + } + + ConnectivityEvent::InterfaceChanged => { + debug!("Network interface changed, connectivity recheck in progress"); + } + } + } + async fn handle_ipc_message( engine: &Arc>, host: &Arc, @@ -440,6 +523,7 @@ impl Service { ipc: &Arc, store: &Arc, rate_limiter: &Arc>, + connectivity: &ConnectivityHandle, msg: ServerMessage, ) { match msg { @@ -458,7 +542,7 @@ impl Service { } let response = - Self::handle_command(engine, host, volume, ipc, store, &client_id, request.request_id, request.command) + Self::handle_command(engine, host, volume, ipc, store, connectivity, &client_id, request.request_id, request.command) .await; let _ = ipc.send_response(&client_id, response).await; @@ -504,6 +588,7 @@ impl Service { volume: &Arc, ipc: &Arc, store: &Arc, + connectivity: &ConnectivityHandle, client_id: &ClientId, request_id: u64, command: Command, @@ -513,7 +598,13 @@ impl Service { match command { Command::GetState => { - let state = engine.lock().await.get_state(); + let mut state = engine.lock().await.get_state(); + // Add connectivity status + state.connectivity = ConnectivityStatus { + connected: connectivity.is_connected().await, + check_url: Some(connectivity.global_check_url().to_string()), + last_check: connectivity.last_check_time().await, + }; Response::success(request_id, ResponsePayload::State(state)) } @@ -526,6 +617,30 @@ impl Service { Command::Launch { entry_id } => { let mut eng = engine.lock().await; + // First check if the entry requires network and if it's available + if let Some(entry) = eng.policy().get_entry(&entry_id) + && entry.network.required + { + let check_url = entry.network.effective_check_url(&eng.policy().network); + let network_ok = connectivity.check_url(check_url).await; + + if !network_ok { + info!( + entry_id = %entry_id, + check_url = %check_url, + "Launch denied: network connectivity check failed" + ); + return Response::success( + request_id, + ResponsePayload::LaunchDenied { + reasons: vec![ReasonCode::NetworkUnavailable { + check_url: check_url.to_string(), + }], + }, + ); + } + } + match eng.request_launch(&entry_id, now) { LaunchDecision::Approved(plan) => { // Start the session in the engine @@ -939,6 +1054,6 @@ async fn main() -> Result<()> { ); // Create and run the service - let service = Service::new(&args).await?; - service.run().await + let (service, connectivity_events) = Service::new(&args).await?; + service.run(connectivity_events).await } diff --git a/crates/shepherdd/tests/integration.rs b/crates/shepherdd/tests/integration.rs index 19658b3..1bdf32d 100644 --- a/crates/shepherdd/tests/integration.rs +++ b/crates/shepherdd/tests/integration.rs @@ -3,7 +3,7 @@ //! These tests verify the end-to-end behavior of shepherdd. use shepherd_api::{EntryKind, WarningSeverity, WarningThreshold}; -use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy, Policy}; +use shepherd_config::{AvailabilityPolicy, Entry, LimitsPolicy, NetworkRequirement, Policy}; use shepherd_core::{CoreEngine, CoreEvent, LaunchDecision}; use shepherd_host_api::{HostCapabilities, MockHost}; use shepherd_store::{SqliteStore, Store}; @@ -48,6 +48,7 @@ fn make_test_policy() -> Policy { }, ], volume: None, + network: NetworkRequirement::default(), disabled: false, disabled_reason: None, }, @@ -55,6 +56,7 @@ fn make_test_policy() -> Policy { default_warnings: vec![], default_max_run: Some(Duration::from_secs(3600)), volume: Default::default(), + network: Default::default(), } }