diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..a1bbfb88 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,5 @@ +# bestool-canopy's build script fetches Canopy's live OpenAPI spec unless +# told to use its committed snapshot. Builds must not depend on network +# reachability, so pin every build (local and CI) to the snapshot. +[env] +CANOPY_OPENAPI_OFFLINE = "1" diff --git a/Cargo.lock b/Cargo.lock index 5fe7a25a..38f7ecc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,51 @@ dependencies = [ "subtle", ] +[[package]] +name = "age" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "047a482d1843edf1ce76ada63183698144030fe1191bd5ddba6e41e164e0bc43" +dependencies = [ + "age-core", + "base64 0.21.7", + "bech32", + "chacha20poly1305", + "cookie-factory", + "futures", + "hmac 0.12.1", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "memchr", + "nom 7.1.3", + "pin-project", + "rand 0.8.6", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99" +dependencies = [ + "base64 0.21.7", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "io_tee", + "nom 7.1.3", + "rand 0.8.6", + "secrecy", + "sha2 0.10.9", +] + [[package]] name = "ahash" version = "0.8.12" @@ -75,6 +120,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "algae-cli" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904db2bebc10c4af40c21866dae385b008883ac57994896d8da815896ba3a63f" +dependencies = [ + "age", + "age-core", + "clap", + "clap-markdown", + "dialoguer", + "diceware_wordlists", + "futures", + "indicatif", + "itertools 0.15.0", + "jiff", + "miette", + "pinentry", + "rand 0.10.1", + "tokio", + "tokio-util", + "tracing", + "windows_exe_info", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -176,6 +246,18 @@ dependencies = [ "password-hash 0.5.0", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "asn1-rs" version = "0.7.1" @@ -185,7 +267,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 2.0.18", @@ -827,12 +909,27 @@ dependencies = [ "windows-link", ] +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -855,6 +952,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bcrypt-pbkdf" version = "0.10.0" @@ -866,6 +972,44 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bestool-canopy" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a31932e57eff5a5aa9cc2e01e78685b30acc083bf65b13decdeeed3cecde5f" +dependencies = [ + "algae-cli", + "base64 0.22.1", + "blake3", + "bon", + "flate2", + "futures", + "getrandom 0.4.2", + "hickory-resolver", + "jiff", + "machine-uid", + "miette", + "prettyplease", + "rcgen", + "reqwest", + "schemars 0.8.22", + "serde", + "serde_json", + "syn", + "sysinfo", + "time", + "tokio", + "tracing", + "typify", + "uuid", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -917,6 +1061,20 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -967,6 +1125,31 @@ dependencies = [ "cipher", ] +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "build-env" version = "0.3.1" @@ -1001,6 +1184,12 @@ dependencies = [ "either", ] +[[package]] +name = "camino" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0" + [[package]] name = "castaway" version = "0.2.4" @@ -1071,13 +1260,26 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "charset" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" dependencies = [ - "base64", + "base64 0.22.1", "encoding_rs", ] @@ -1116,6 +1318,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", "inout", + "zeroize", ] [[package]] @@ -1128,6 +1331,15 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-markdown" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2a2617956a06d4885b490697b5307ebb09fec10b088afc18c81762d848c2339" +dependencies = [ + "clap", +] + [[package]] name = "clap_builder" version = "4.6.0" @@ -1138,6 +1350,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", ] [[package]] @@ -1213,6 +1426,18 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1245,6 +1470,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -1254,6 +1485,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1565,7 +1805,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -1633,6 +1873,23 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", + "zeroize", +] + +[[package]] +name = "diceware_wordlists" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f9b52b69d268c7a2bc582e3aec5cdfa43ac91cef4fe6b6751b02da2b43d6166" + [[package]] name = "digest" version = "0.10.7" @@ -1699,6 +1956,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1796,6 +2063,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.2+spec-1.1.0", + "vswhom", + "winreg", +] + [[package]] name = "enable-ansi-support" version = "0.3.1" @@ -1805,6 +2086,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1941,6 +2228,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml 0.5.11", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1963,6 +2259,50 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2269,6 +2609,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -2384,6 +2726,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2568,7 +2919,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -2588,45 +2939,111 @@ dependencies = [ ] [[package]] -name = "iana-time-zone" -version = "0.1.65" +name = "i18n-config" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", + "basic-toml", "log", - "wasm-bindgen", - "windows-core", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "i18n-embed" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4" dependencies = [ - "cc", + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", ] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "i18n-embed-fl" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d" dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" @@ -2748,6 +3165,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "tokio", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + [[package]] name = "inout" version = "0.1.4" @@ -2766,7 +3197,7 @@ checksum = "9f05ad37c421b962354c358d347d4a6130151df9407978372d3ad7f0c8f71a64" dependencies = [ "async-trait", "aws-lc-rs", - "base64", + "base64 0.22.1", "bytes", "http 1.4.0", "http-body 1.0.1", @@ -2785,6 +3216,31 @@ dependencies = [ "tokio", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ipconfig" version = "0.3.4" @@ -2828,6 +3284,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3071,9 +3536,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lru-slab" @@ -3081,6 +3546,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "machine-uid" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe0d6336d341b10ae80e099b8f3c59f34bf8aef66ef25e83aa98bf2955c0ae7" +dependencies = [ + "libc", + "windows-registry", + "windows-sys 0.61.2", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3117,6 +3593,36 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "mime" version = "0.3.17" @@ -3238,7 +3744,7 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c57e7343eed9e9330e084eef12651b15be3c8ed7825915a0ffa33736b852bed" dependencies = [ - "schemars", + "schemars 1.2.1", "serde", "serde_json", "serde_path_to_error", @@ -3294,6 +3800,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3338,6 +3862,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3345,6 +3878,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "objc2", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-open-directory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb82bed227edf5201dfedf072bba4015a33d3d4a98519837295a90f0a23f676d" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -3443,6 +4015,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "p256" version = "0.13.2" @@ -3547,6 +4125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -3555,7 +4134,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -3584,6 +4163,26 @@ dependencies = [ "indexmap", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3596,6 +4195,21 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pinentry" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014e8043c4a1705b519f37562a413378af00d0da7e423c087c6e4cb9cd770875" +dependencies = [ + "log", + "nom 8.0.0", + "percent-encoding", + "secrecy", + "wait-timeout", + "which", + "zeroize", +] + [[package]] name = "piper" version = "0.2.5" @@ -3815,7 +4429,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.2", "rustls 0.23.41", "socket2 0.6.3", "thiserror 2.0.18", @@ -3837,7 +4451,7 @@ dependencies = [ "lru-slab", "rand 0.9.4", "ring", - "rustc-hash", + "rustc-hash 2.1.2", "rustls 0.23.41", "rustls-pki-types", "rustls-platform-verifier 0.6.2", @@ -4107,16 +4721,28 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "regress" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + [[package]] name = "reqwest" version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", + "futures-util", "h2 0.4.13", "http 1.4.0", "http-body 1.0.1", @@ -4159,7 +4785,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b36447d5e70933adee73cefff22b0851edbc162f2b3951918de43717a22d92de" dependencies = [ - "base64", + "base64 0.22.1", "charset", "chumsky", "memchr", @@ -4388,6 +5014,12 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4409,7 +5041,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -4557,6 +5189,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4575,6 +5216,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.1" @@ -4583,11 +5236,23 @@ checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.1", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "schemars_derive" version = "1.2.1" @@ -4606,6 +5271,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sct" version = "0.7.1" @@ -4685,14 +5361,18 @@ dependencies = [ name = "seedling-core" version = "0.4.5" dependencies = [ + "algae-cli", "arc-swap", "aws-config", "aws-credential-types", "aws-sdk-route53", "axum", + "base64 0.22.1", + "bestool-canopy", "clap", "cronexpr", "ed25519-dalek", + "futures", "futures-util", "hickory-resolver", "iana-time-zone", @@ -4703,6 +5383,7 @@ dependencies = [ "nftables", "nix 0.31.3", "orion", + "p256", "parking_lot", "pem", "podman-rest-client", @@ -4801,7 +5482,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", - "toml", + "toml 1.1.2+spec-1.1.0", "tower", "tower-http 0.7.0", "tracing", @@ -4809,11 +5490,30 @@ dependencies = [ "wtransport", ] +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.3.0", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" @@ -4901,6 +5601,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4961,6 +5673,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -5189,6 +5907,18 @@ dependencies = [ "is_ci", ] +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "symlink" version = "0.1.0" @@ -5197,9 +5927,9 @@ checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -5226,6 +5956,21 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.39.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "objc2-open-directory", + "windows", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -5281,6 +6026,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thin-vec" version = "0.2.18" @@ -5385,6 +6150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5471,11 +6237,21 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -5682,12 +6458,68 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "typify" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cdc2e612ea322c6e232d46a0b34607c8eb28978fd6060ecfb139f2a50db8d5f" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "691591f49550c0d371bc441d019c30ce241d4116aee7d68df7a9840d6c70bf8f" +dependencies = [ + "heck", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d41aea893c49cf95661389207b8af0c6254ff48b2c3ae1e4f3704777dbdfcf03" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn", + "typify-impl", +] + [[package]] name = "uds_windows" version = "1.2.1" @@ -5699,6 +6531,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + [[package]] name = "unicase" version = "2.9.0" @@ -5711,18 +6562,42 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "universal-hash" version = "0.5.1" @@ -5818,6 +6693,35 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -6003,6 +6907,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + [[package]] name = "whoami" version = "2.1.2" @@ -6059,6 +6972,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -6072,6 +7006,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6100,6 +7045,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -6156,6 +7111,15 @@ 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]] name = "windows-sys" version = "0.60.2" @@ -6237,6 +7201,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -6285,6 +7258,16 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_exe_info" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a7c2cd292e8e58e012eaf18f18f6b64ef74e0b90677b4f9e1f15bfca24056c7" +dependencies = [ + "camino", + "embed-resource", +] + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6426,6 +7409,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -6562,6 +7555,18 @@ dependencies = [ "url", ] +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -6573,7 +7578,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "ring", "rusticata-macros", @@ -6738,6 +7743,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" @@ -6756,6 +7775,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -6827,7 +7847,7 @@ dependencies = [ "chrono", "derive_builder", "fancy-regex", - "itertools", + "itertools 0.14.0", "lazy_static", "regex", "time", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 72c53283..829a4ed1 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -54,6 +54,11 @@ aws-credential-types = "1.2.14" axum.workspace = true iana-time-zone = "0.1.65" hickory-resolver = { version = "0.26.1", default-features = false, features = ["system-config", "tokio"] } +bestool-canopy = "0.7.0" +algae-cli = "1.1.6" +base64 = "0.22.1" +p256 = { version = "0.13.2", features = ["ecdsa", "pkcs8", "pem"] } +futures = { version = "0.3.32", default-features = false, features = ["executor"] } [dev-dependencies] time = "0.3.47" diff --git a/crates/core/src/oi/handler.rs b/crates/core/src/oi/handler.rs index c332e7d5..2506ab2a 100644 --- a/crates/core/src/oi/handler.rs +++ b/crates/core/src/oi/handler.rs @@ -14,6 +14,7 @@ pub mod actions; mod appdef_json; mod apps; pub mod backups; +mod canopy; mod faults; mod images; mod ingresses; @@ -26,6 +27,7 @@ mod templates; mod tls; mod volumes; +pub(crate) use canopy::{deregister as canopy_deregister, enrol as canopy_enrol}; pub(crate) use status::get_infra_status; /// Context derived from an incoming OI request, passed through dispatch. @@ -342,6 +344,8 @@ fn parse_and_dispatch(state: &Arc, buf: &[u8], ctx: &RequestCtx) -> Han "/tls/retry-blocks/set" => tls::set_retry_block(state, parse_params(req.params)?), // i[tls.retry-block.clear] "/tls/retry-blocks/clear" => tls::clear_retry_block(state, parse_params(req.params)?), + // i[canopy.status] + "/canopy/status" => canopy::status(state), other => Err(OiError::not_found(format!("unknown method: {other}"))), }; diff --git a/crates/core/src/oi/handler/canopy.rs b/crates/core/src/oi/handler/canopy.rs new file mode 100644 index 00000000..335e0410 --- /dev/null +++ b/crates/core/src/oi/handler/canopy.rs @@ -0,0 +1,86 @@ +//! OI surface for the Canopy link: enrolment, status, and deregistration. +//! The reporting behaviour lives in [`crate::runtime::canopy`]. + +use std::sync::Arc; + +use serde::Deserialize; +use serde_json::json; + +use seedling_protocol::error::{ErrorCode, HandlerResult, OiError}; + +use super::OiState; +use crate::runtime::canopy::{CanopyError, CanopyProvider}; + +fn provider(state: &OiState) -> Result<&Arc, OiError> { + state.canopy_provider.as_ref().ok_or_else(|| { + OiError::new( + ErrorCode::Internal, + "canopy reporting is not available on this instance", + ) + }) +} + +fn to_oi_error(e: CanopyError) -> OiError { + let code = match &e { + CanopyError::AlreadyEnrolled => ErrorCode::AlreadyEnrolled, + CanopyError::InvalidTicket { .. } + | CanopyError::Decrypt { .. } + | CanopyError::Rejected { .. } => ErrorCode::RequirementsInvalid, + CanopyError::Internal { .. } => ErrorCode::Internal, + }; + OiError::new(code, e.to_string()) +} + +#[derive(Deserialize)] +pub(crate) struct EnrolParams { + ticket: String, + passphrase: String, +} + +// i[canopy.enrol] +pub(crate) async fn enrol(state: &Arc, params: EnrolParams) -> HandlerResult { + let (server_id, device_id) = provider(state)? + .enrol(¶ms.ticket, ¶ms.passphrase) + .await + .map_err(to_oi_error)?; + Ok(json!({ "server_id": server_id, "device_id": device_id })) +} + +// i[impl canopy.status] +pub(crate) fn status(state: &OiState) -> HandlerResult { + let provider = provider(state)?; + let mut body = serde_json::Map::new(); + match provider.registration_info() { + Some(info) => { + body.insert("enrolled".into(), true.into()); + body.insert("server_id".into(), info.server_id.into()); + if let Some(device_id) = info.device_id { + body.insert("device_id".into(), device_id.into()); + } + body.insert("api_url".into(), info.api_url.into()); + } + None => { + body.insert("enrolled".into(), false.into()); + } + } + let push = provider.push_status(); + if let Some(at) = push.last_push_at { + body.insert("last_push_at".into(), at.to_string().into()); + } + if let Some(err) = push.last_push_error { + body.insert("last_push_error".into(), err.into()); + } + if let Some(response) = push.last_response { + body.insert("last_response".into(), response); + } + Ok(body.into()) +} + +// i[canopy.deregister] +pub(crate) async fn deregister(state: &Arc) -> HandlerResult { + let deregistered = provider(state)?.deregister().await.map_err(to_oi_error)?; + Ok(json!({ "deregistered": deregistered })) +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/oi/handler/canopy/tests.rs b/crates/core/src/oi/handler/canopy/tests.rs new file mode 100644 index 00000000..b18b8958 --- /dev/null +++ b/crates/core/src/oi/handler/canopy/tests.rs @@ -0,0 +1,13 @@ +use serde_json::json; + +use crate::oi::test_support::TestOi; + +// i[verify canopy.status] +#[test] +fn status_reports_not_enrolled() { + let oi = TestOi::new(); + let result = oi.call("/canopy/status", json!({})).unwrap(); + assert_eq!(result["enrolled"], json!(false)); + assert!(result.get("server_id").is_none()); + assert!(result.get("last_push_at").is_none()); +} diff --git a/crates/core/src/oi/server.rs b/crates/core/src/oi/server.rs index 7818afef..9afd5073 100644 --- a/crates/core/src/oi/server.rs +++ b/crates/core/src/oi/server.rs @@ -468,6 +468,53 @@ async fn handle_bidi_stream( return; } + // i[impl canopy.enrol] — the enrolment handshake awaits network calls, so + // it runs here rather than in the synchronous dispatch. + if maybe_method.as_deref() == Some("/canopy/enrol") { + let result = match first_obj + .get("params") + .cloned() + .map(serde_json::from_value) + .transpose() + { + Ok(Some(params)) => super::handler::canopy_enrol(&state, params).await, + Ok(None) | Err(_) => Err(seedling_protocol::error::OiError::new( + seedling_protocol::error::ErrorCode::RequirementsInvalid, + "invalid params: expected { ticket, passphrase }", + )), + }; + let response = match result { + Ok(v) => serde_json::to_vec(&json!({ "result": v })).expect("serialisation"), + Err(e) => serde_json::to_vec(&json!({ + "error": { "code": e.code, "message": e.message } + })) + .expect("serialisation"), + }; + if let Err(e) = send.write_all(&response).await { + tracing::warn!("stream write error: {e}"); + } + let _ = send.finish(); + return; + } + + // i[impl canopy.deregister] — removing the stored registration awaits + // filesystem work, so it also runs outside the synchronous dispatch. + if maybe_method.as_deref() == Some("/canopy/deregister") { + let result = super::handler::canopy_deregister(&state).await; + let response = match result { + Ok(v) => serde_json::to_vec(&json!({ "result": v })).expect("serialisation"), + Err(e) => serde_json::to_vec(&json!({ + "error": { "code": e.code, "message": e.message } + })) + .expect("serialisation"), + }; + if let Err(e) = send.write_all(&response).await { + tracing::warn!("stream write error: {e}"); + } + let _ = send.finish(); + return; + } + // i[forward.request] — /forwards/start keeps the control stream open for the // duration of the forward; it must be handled outside the normal req/resp path. if maybe_method.as_deref() == Some("/forwards/start") { diff --git a/crates/core/src/oi/state.rs b/crates/core/src/oi/state.rs index d2a45231..e795028d 100644 --- a/crates/core/src/oi/state.rs +++ b/crates/core/src/oi/state.rs @@ -57,4 +57,8 @@ pub struct OiState { /// snapshots to operators. `None` in test harnesses. // r[impl service.site.address] pub site_resolver: Option>, + /// Canopy reporting provider. Holds the registration and the reporting + /// loop; the OI routes enrolment, status, and deregistration through it. + /// `None` in test harnesses. + pub canopy_provider: Option>, } diff --git a/crates/core/src/oi/test_support.rs b/crates/core/src/oi/test_support.rs index b90508bc..91530027 100644 --- a/crates/core/src/oi/test_support.rs +++ b/crates/core/src/oi/test_support.rs @@ -66,8 +66,15 @@ impl TestOi { session: None, }); + let registry = Arc::new(RwLock::new(AppRegistry::new())); + let canopy_provider = crate::runtime::canopy::CanopyProvider::new( + data_dir.path(), + Arc::clone(®istry), + Arc::clone(&driver.container), + ); + let state = Arc::new(OiState { - registry: Arc::new(RwLock::new(AppRegistry::new())), + registry, spki_fingerprint: OnceLock::new(), start_time: Instant::now(), db: db.clone(), @@ -88,6 +95,7 @@ impl TestOi { caddy_data_path: tokio::sync::OnceCell::new(), tailscale_provider: None, site_resolver: None, + canopy_provider: Some(canopy_provider), }); let ctx = RequestCtx { diff --git a/crates/core/src/runtime.rs b/crates/core/src/runtime.rs index 8f630179..d34609a8 100644 --- a/crates/core/src/runtime.rs +++ b/crates/core/src/runtime.rs @@ -10,6 +10,7 @@ pub mod backup_apps; pub mod backup_execution; pub mod backup_strategies; pub mod barrier; +pub mod canopy; pub mod db; pub mod desired; pub mod external_service_mappings; diff --git a/crates/core/src/runtime/canopy.rs b/crates/core/src/runtime/canopy.rs new file mode 100644 index 00000000..43ec75c5 --- /dev/null +++ b/crates/core/src/runtime/canopy.rs @@ -0,0 +1,655 @@ +//! Canopy reporting provider. +//! +//! Holds this instance's Canopy registration and, while one is present, +//! pushes a status report to Canopy on a fixed cadence. Enrolment claims a +//! server record the operator pre-created in Canopy: the operator-supplied +//! ticket is decrypted with its passphrase, a fresh device key is minted, +//! and possession of that key is proven to Canopy over a begin/complete +//! challenge. The stored registration is encrypted at rest and bound to the +//! machine by the registration store. + +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::{Duration, Instant}, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use bestool_canopy::{ + CanopyClient, ClientBuilderFactory, TAILSCALE_URL, device_identity, + registration::{self, Registration}, + schema::{BeginArgs, BeginResponse, CompleteArgs, CompleteResponse}, + tailscale_client, +}; +use jiff::Timestamp; +use p256::{ + SecretKey, + ecdsa::{Signature, SigningKey, signature::Signer as _}, + pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _, EncodePublicKey as _}, +}; +use parking_lot::RwLock; +use serde::Deserialize; +use serde_json::{Value, json}; +use snafu::Snafu; +use tokio::{sync::Notify, task::JoinHandle}; +use tracing::{debug, error, info, warn}; + +use crate::{runtime::apps::AppRegistry, system::ContainerRuntime}; + +// r[canopy.push] +/// How often an enrolled instance reports to Canopy. +const PUSH_INTERVAL: Duration = Duration::from_secs(60); + +// r[canopy.push.fault] +/// Consecutive report failures tolerated before escalating to error-level +/// logging. +const ESCALATE_AFTER_FAILURES: u32 = 5; + +/// How long each enrolment handshake request may take. +const ENROL_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Snafu)] +pub enum CanopyError { + // i[canopy.enrol.single] + #[snafu(display("this instance already holds a Canopy registration"))] + AlreadyEnrolled, + #[snafu(display("invalid enrolment ticket: {reason}"))] + InvalidTicket { reason: String }, + #[snafu(display("could not decrypt enrolment ticket (wrong passphrase?): {reason}"))] + Decrypt { reason: String }, + #[snafu(display("canopy rejected the enrolment: {reason}"))] + Rejected { reason: String }, + #[snafu(display("enrolment failed: {reason}"))] + Internal { reason: String }, +} + +/// The decrypted enrolment ticket payload. +/// +/// No `Debug` derive on purpose: `token` is a bearer secret and must never +/// be logged. +#[derive(Deserialize)] +struct EnrolTicket { + v: String, + api_url: String, + server_id: String, + token: String, +} + +/// The non-secret half of a stored registration, kept in memory so the OI +/// status surface never has to touch the encrypted store. +#[derive(Debug, Clone)] +pub struct RegistrationInfo { + pub server_id: String, + pub device_id: Option, + pub api_url: String, +} + +/// Reporting state the OI status surface reads. +#[derive(Debug, Clone, Default)] +pub struct PushStatus { + pub last_push_at: Option, + pub last_push_error: Option, + pub last_response: Option, +} + +pub struct CanopyProvider { + /// Directory holding the encrypted registration (`{data_dir}/canopy`). + dir: PathBuf, + registry: Arc>, + container_runtime: Arc, + start_time: Instant, + kick: Notify, + registration: RwLock>, + client: RwLock>>, + push_status: RwLock, + /// Serialises enrolment and deregistration, so concurrent enrol calls + /// cannot both claim a server record (each would consume an enrolment + /// token, and one registration would silently overwrite the other), and + /// an enrolment cannot interleave with a removal. + lifecycle: tokio::sync::Mutex<()>, +} + +impl CanopyProvider { + pub fn new( + data_dir: &Path, + registry: Arc>, + container_runtime: Arc, + ) -> Arc { + Arc::new(Self { + dir: data_dir.join("canopy"), + registry, + container_runtime, + start_time: Instant::now(), + kick: Notify::new(), + registration: RwLock::new(None), + client: RwLock::new(None), + push_status: RwLock::new(PushStatus::default()), + lifecycle: tokio::sync::Mutex::new(()), + }) + } + + pub fn registration_info(&self) -> Option { + self.registration.read().clone() + } + + pub fn push_status(&self) -> PushStatus { + self.push_status.read().clone() + } + + /// Spawn the reporting loop. Loads any stored registration first, so an + /// enrolled instance resumes reporting across restarts. + pub fn spawn(self: Arc) -> JoinHandle<()> { + let provider = Arc::clone(&self); + tokio::spawn(async move { + provider.load_stored_registration().await; + provider.run().await; + }) + } + + // r[impl canopy.registration] + async fn load_stored_registration(&self) { + match registration::load_from(&self.dir).await { + Ok(Some(reg)) => { + if let Err(e) = self.adopt_registration(reg).await { + warn!("canopy: stored registration unusable: {e}"); + } + } + Ok(None) => debug!("canopy: no registration; reporting disabled"), + Err(e) => warn!("canopy: could not read stored registration: {e:#}"), + } + } + + /// Record a registration's non-secret info and build the reporting + /// client from its device key. + async fn adopt_registration(&self, reg: Registration) -> Result<(), CanopyError> { + let (Some(server_id), Some(api_url), Some(device_key)) = ( + reg.server_id.clone(), + reg.api_url.clone(), + reg.device_key.clone(), + ) else { + return Err(CanopyError::Internal { + reason: "stored registration is missing fields".into(), + }); + }; + + let base_url = api_url.parse().map_err(|e| CanopyError::Internal { + reason: format!("stored api_url invalid: {e}"), + })?; + let tailscale_url = TAILSCALE_URL.parse().expect("static URL is valid"); + let client = + CanopyClient::with_urls(base_url, tailscale_url, Some(&device_key), client_builder) + .await + .map_err(|e| CanopyError::Internal { + reason: format!("building canopy client: {e:#}"), + })? + .ok_or_else(|| CanopyError::Internal { + reason: "no route to canopy (no tailnet and no device key)".into(), + })?; + + *self.registration.write() = Some(RegistrationInfo { + server_id, + device_id: reg.device_id.clone(), + api_url, + }); + *self.client.write() = Some(Arc::new(client)); + Ok(()) + } + + // r[impl canopy.push] + async fn run(self: Arc) { + let mut consecutive_failures: u32 = 0; + loop { + if let Some((client, server_id)) = self.reporting_handles() { + let outcome = self.push_once(&client, &server_id).await; + if self.client.read().is_none() { + // Deregistered while the report was in flight; don't + // write stale push state onto an unenrolled instance. + continue; + } + match outcome { + Ok(response) => { + consecutive_failures = 0; + // r[impl canopy.push.response] + info!(response = %response, "canopy: report accepted"); + *self.push_status.write() = PushStatus { + last_push_at: Some(Timestamp::now()), + last_push_error: None, + last_response: Some(response), + }; + } + Err(reason) => { + consecutive_failures += 1; + // r[impl canopy.push.fault] + if consecutive_failures >= ESCALATE_AFTER_FAILURES { + error!( + attempt = consecutive_failures, + "canopy: report failed: {reason}" + ); + } else { + warn!( + attempt = consecutive_failures, + "canopy: report failed: {reason}" + ); + } + let mut status = self.push_status.write(); + status.last_push_at = Some(Timestamp::now()); + status.last_push_error = Some(reason); + } + } + } + + tokio::select! { + _ = tokio::time::sleep(PUSH_INTERVAL) => {} + _ = self.kick.notified() => { + debug!("canopy: immediate report triggered"); + } + } + } + } + + fn reporting_handles(&self) -> Option<(Arc, String)> { + let client = self.client.read().clone()?; + let server_id = self.registration.read().as_ref()?.server_id.clone(); + Some((client, server_id)) + } + + async fn push_once(&self, client: &CanopyClient, server_id: &str) -> Result { + let payload = self.build_payload().await; + let response = client + .status(server_id, &payload) + .await + .map_err(|e| format!("{e:#}"))?; + serde_json::to_value(&response).map_err(|e| format!("encoding response: {e}")) + } + + // r[impl canopy.push] + async fn build_payload(&self) -> Value { + let proxy = self + .component_state(&["seedling-caddy-blue", "seedling-caddy-green"]) + .await; + let resolver = self + .component_state(&["seedling-resolver-blue", "seedling-resolver-green"]) + .await; + + let (apps_total, apps_running) = { + let reg = self.registry.read(); + let apps = reg.list(); + let running = apps + .iter() + .filter(|(_, status)| status.name() == "running") + .count(); + (apps.len(), running) + }; + let apps_check = if apps_total == 0 { + json!({ "check": "apps", "result": "passed", "summary": "no apps registered" }) + } else if apps_running == apps_total { + json!({ + "check": "apps", + "result": "passed", + "summary": format!("{apps_total} apps running"), + }) + } else { + json!({ + "check": "apps", + "result": "warning", + "summary": format!("{apps_running}/{apps_total} apps running"), + }) + }; + + let hostname = whoami::devicename() + .or_else(|_| whoami::hostname()) + .unwrap_or_else(|_| "unknown".into()); + + json!({ + "source": "seedling", + "seedlingVersion": env!("CARGO_PKG_VERSION"), + "hostname": hostname, + "uptimeSecs": self.start_time.elapsed().as_secs(), + "health": [ + component_check("proxy", "reverse proxy", proxy), + component_check("resolver", "DNS resolver", resolver), + apps_check, + ], + }) + } + + async fn component_state(&self, containers: &[&str]) -> &'static str { + use crate::system::ContainerStatus; + for name in containers { + if let Ok(Some(s)) = self.container_runtime.inspect(name).await + && matches!(s.status, ContainerStatus::Running) + { + return "running"; + } + } + "stopped" + } + + // i[impl canopy.enrol] + pub async fn enrol( + &self, + ticket_b64: &str, + passphrase: &str, + ) -> Result<(String, String), CanopyError> { + let _lifecycle = self.lifecycle.lock().await; + // i[impl canopy.enrol.single] + if self.registration.read().is_some() { + return Err(CanopyError::AlreadyEnrolled); + } + + let encrypted = STANDARD + .decode(ticket_b64.split_whitespace().collect::()) + .map_err(|e| CanopyError::InvalidTicket { + reason: format!("not base64: {e}"), + })?; + // Reject a bogus ticket before touching the passphrase, so a failed + // decode can't be mistaken for a wrong passphrase. + if !encrypted.starts_with(b"age-encryption.org/v1") { + return Err(CanopyError::InvalidTicket { + reason: "not a Canopy enrolment ticket".into(), + }); + } + + let ticket = { + let passphrase = passphrase.to_owned(); + tokio::task::spawn_blocking(move || decrypt_ticket(&encrypted, &passphrase)) + .await + .map_err(|e| CanopyError::Internal { + reason: format!("decrypt task failed: {e}"), + })?? + }; + if ticket.v != "enroll-1" { + return Err(CanopyError::InvalidTicket { + reason: format!("unsupported ticket version {:?}", ticket.v), + }); + } + let api_url: bestool_canopy::reqwest::Url = + ticket + .api_url + .parse() + .map_err(|e| CanopyError::InvalidTicket { + reason: format!("api_url is not a valid URL: {e}"), + })?; + if api_url.scheme() != "https" { + return Err(CanopyError::InvalidTicket { + reason: format!("api_url must be https, got {:?}", api_url.scheme()), + }); + } + let server_id = + uuid::Uuid::parse_str(&ticket.server_id).map_err(|e| CanopyError::InvalidTicket { + reason: format!("server_id is not a valid UUID: {e}"), + })?; + + // Mint a fresh device key: the signature and SPKI derive from it, + // and it becomes this instance's identity to Canopy. + let secret = SecretKey::random(&mut rand_core::OsRng); + let device_key_pem = secret + .to_pkcs8_pem(p256::pkcs8::LineEnding::LF) + .map_err(|e| CanopyError::Internal { + reason: format!("encoding device key: {e}"), + })? + .to_string(); + let spki_der = secret + .public_key() + .to_public_key_der() + .map_err(|e| CanopyError::Internal { + reason: format!("encoding device public key: {e}"), + })? + .as_bytes() + .to_vec(); + let signing_key = + SigningKey::from_pkcs8_pem(&device_key_pem).map_err(|e| CanopyError::Internal { + reason: format!("loading device key for signing: {e}"), + })?; + + let transport = enrolment_transport(&device_key_pem).await?; + let spki_b64 = STANDARD.encode(&spki_der); + + // Step 1: begin — fetch the challenge nonce. + let begin: BeginResponse = post_step( + &transport, + &api_url, + "begin", + &BeginArgs::builder() + .server_id(server_id) + .token(ticket.token.clone()) + .maybe_spki(transport.carries_spki_in_body().then(|| spki_b64.clone())) + .build(), + ) + .await?; + if begin.channel_binding_required { + return Err(CanopyError::Rejected { + reason: "canopy requires TLS channel binding, which seedling does not support yet" + .into(), + }); + } + let nonce_bytes = + STANDARD + .decode(begin.nonce.trim()) + .map_err(|e| CanopyError::Rejected { + reason: format!("decoding challenge nonce: {e}"), + })?; + + // Step 2: prove possession of the device key by signing the + // transcript: nonce || server id bytes || SPKI DER. + let mut transcript = Vec::with_capacity(nonce_bytes.len() + 16 + spki_der.len()); + transcript.extend_from_slice(&nonce_bytes); + transcript.extend_from_slice(server_id.as_bytes()); + transcript.extend_from_slice(&spki_der); + let signature: Signature = signing_key.sign(&transcript); + let signature_b64 = STANDARD.encode(signature.to_der().as_bytes()); + + let complete: CompleteResponse = post_step( + &transport, + &api_url, + "complete", + &CompleteArgs::builder() + .server_id(server_id) + .nonce(begin.nonce.clone()) + .signature(signature_b64) + .maybe_spki(transport.carries_spki_in_body().then(|| spki_b64.clone())) + .build(), + ) + .await?; + + // r[impl canopy.registration] + let reg = Registration { + server_id: Some(complete.server_id.to_string()), + device_key: Some(device_key_pem), + device_id: Some(complete.device_id.to_string()), + api_url: Some(api_url.to_string()), + ..Registration::default() + }; + registration::store_in(&self.dir, ®) + .await + .map_err(|e| CanopyError::Internal { + reason: format!("storing registration: {e:#}"), + })?; + + self.adopt_registration(reg).await?; + info!(server_id = %complete.server_id, device_id = %complete.device_id, "canopy: enrolled"); + // Report immediately so the operator sees the channel work end to end. + self.kick.notify_one(); + Ok(( + complete.server_id.to_string(), + complete.device_id.to_string(), + )) + } + + // i[impl canopy.deregister] + pub async fn deregister(&self) -> Result { + let _lifecycle = self.lifecycle.lock().await; + let removed = + registration::delete_in(&self.dir) + .await + .map_err(|e| CanopyError::Internal { + reason: format!("removing registration: {e:#}"), + })?; + *self.client.write() = None; + *self.registration.write() = None; + *self.push_status.write() = PushStatus::default(); + if removed { + info!("canopy: deregistered"); + } + Ok(removed) + } +} + +fn client_builder() -> bestool_canopy::reqwest::ClientBuilder { + bestool_canopy::reqwest::ClientBuilder::new() +} + +fn component_check(name: &str, label: &str, state: &'static str) -> Value { + if state == "running" { + json!({ "check": name, "result": "passed", "summary": format!("{label} running") }) + } else { + json!({ "check": name, "result": "failed", "summary": format!("{label} {state}") }) + } +} + +/// Synchronous on purpose: the passphrase identity is not `Send`, so the +/// decrypt future must be driven to completion on one thread (see the +/// `spawn_blocking` at the call site — scrypt key derivation is CPU-bound). +fn decrypt_ticket(encrypted: &[u8], passphrase: &str) -> Result { + let pass = + algae_cli::passphrases::Passphrase::new(secrecy::SecretString::from(passphrase.to_owned())); + let reader = futures_util::io::Cursor::new(encrypted.to_vec()); + let mut plaintext: Vec = Vec::new(); + futures::executor::block_on(algae_cli::streams::decrypt_stream( + reader, + &mut plaintext, + Box::new(pass), + )) + .map_err(|e| CanopyError::Decrypt { + reason: format!("{e:#}"), + })?; + serde_json::from_slice(&plaintext).map_err(|e| CanopyError::InvalidTicket { + reason: format!("decrypted ticket is not valid JSON: {e}"), + }) +} + +/// Which network path the enrolment handshake takes: the canopy tailnet when +/// reachable (SPKI carried in the body), otherwise public mTLS against the +/// ticket's api_url (SPKI read from the client certificate). +enum EnrolTransport { + Tailscale(bestool_canopy::reqwest::Client), + Mtls(bestool_canopy::reqwest::Client), +} + +impl EnrolTransport { + fn client(&self) -> &bestool_canopy::reqwest::Client { + match self { + Self::Tailscale(c) | Self::Mtls(c) => c, + } + } + + fn carries_spki_in_body(&self) -> bool { + matches!(self, Self::Tailscale(_)) + } + + fn url( + &self, + api_url: &bestool_canopy::reqwest::Url, + step: &str, + ) -> Result { + let raw = match self { + Self::Tailscale(_) => format!("{TAILSCALE_URL}/public/servers/register/{step}"), + Self::Mtls(_) => { + return api_url + .join(&format!("/servers/register/{step}")) + .map_err(|e| CanopyError::Internal { + reason: format!("building register/{step} URL: {e}"), + }); + } + }; + raw.parse().map_err(|e| CanopyError::Internal { + reason: format!("building register/{step} URL: {e}"), + }) + } +} + +async fn enrolment_transport(device_key_pem: &str) -> Result { + let factory: ClientBuilderFactory = Arc::new(client_builder); + if let Some(client) = tailscale_client(&factory).await { + debug!("canopy: enrolling over the canopy tailnet"); + return Ok(EnrolTransport::Tailscale(client)); + } + debug!("canopy: tailnet unreachable; enrolling over public mTLS"); + let identity = device_identity(device_key_pem).map_err(|e| CanopyError::Internal { + reason: format!("building device identity: {e:#}"), + })?; + let client = client_builder() + .identity(identity) + .use_rustls_tls() + .timeout(ENROL_TIMEOUT) + .build() + .map_err(|e| CanopyError::Internal { + reason: format!("building mTLS client: {e}"), + })?; + Ok(EnrolTransport::Mtls(client)) +} + +/// RFC-7807-style problem body. Canopy's register errors are intentionally +/// opaque; surface whatever title/detail it gives. +#[derive(Deserialize)] +struct Problem { + title: Option, + detail: Option, +} + +async fn post_step( + transport: &EnrolTransport, + api_url: &bestool_canopy::reqwest::Url, + step: &str, + body: &impl serde::Serialize, +) -> Result { + let url = transport.url(api_url, step)?; + // The tailnet client carries no total request timeout of its own, so + // bound the request here — a wedged Canopy must not hang the operator's + // enrolment request indefinitely. + let resp = tokio::time::timeout( + ENROL_TIMEOUT, + transport.client().post(url).json(body).send(), + ) + .await + .map_err(|_| CanopyError::Rejected { + reason: format!("register/{step} timed out"), + })? + .map_err(|e| CanopyError::Rejected { + reason: format!("calling register/{step}: {e}"), + })?; + + let status = resp.status(); + let bytes = tokio::time::timeout(ENROL_TIMEOUT, resp.bytes()) + .await + .map_err(|_| CanopyError::Rejected { + reason: format!("register/{step} timed out"), + })? + .map_err(|e| CanopyError::Rejected { + reason: format!("reading register/{step} response: {e}"), + })?; + + if status.is_success() { + return serde_json::from_slice(&bytes).map_err(|e| CanopyError::Rejected { + reason: format!("parsing register/{step} response: {e}"), + }); + } + + let reason = match serde_json::from_slice::(&bytes) { + Ok(problem) => { + let title = problem.title.unwrap_or_else(|| "enrolment failed".into()); + match problem.detail { + Some(detail) => format!("register/{step} ({status}): {title}: {detail}"), + None => format!("register/{step} ({status}): {title}"), + } + } + Err(_) => format!( + "register/{step} ({status}): {}", + String::from_utf8_lossy(&bytes) + ), + }; + Err(CanopyError::Rejected { reason }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/core/src/runtime/canopy/tests.rs b/crates/core/src/runtime/canopy/tests.rs new file mode 100644 index 00000000..0dce9132 --- /dev/null +++ b/crates/core/src/runtime/canopy/tests.rs @@ -0,0 +1,179 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde_json::json; + +use super::*; +use crate::oi::test_support::TestOi; + +fn provider(oi: &TestOi) -> Arc { + Arc::clone(oi.state.canopy_provider.as_ref().unwrap()) +} + +fn runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap() +} + +async fn encrypt_ticket(plaintext: &[u8], passphrase: &str) -> String { + let pass = + algae_cli::passphrases::Passphrase::new(secrecy::SecretString::from(passphrase.to_owned())); + let mut ciphertext: Vec = Vec::new(); + algae_cli::streams::encrypt_stream( + tokio::io::BufReader::new(plaintext), + futures_util::io::Cursor::new(&mut ciphertext), + Box::new(pass), + ) + .await + .unwrap(); + STANDARD.encode(&ciphertext) +} + +// i[verify canopy.enrol] +#[test] +fn enrol_rejects_malformed_tickets() { + let oi = TestOi::new(); + let provider = provider(&oi); + let rt = runtime(); + + let err = rt + .block_on(provider.enrol("not base64 !!!", "pass")) + .unwrap_err(); + assert!(matches!(err, CanopyError::InvalidTicket { .. }), "{err}"); + + let not_age = STANDARD.encode(b"junk that is not an age ciphertext"); + let err = rt.block_on(provider.enrol(¬_age, "pass")).unwrap_err(); + assert!(matches!(err, CanopyError::InvalidTicket { .. }), "{err}"); +} + +// i[verify canopy.enrol] +#[test] +fn enrol_rejects_wrong_passphrase_and_bad_version() { + let oi = TestOi::new(); + let provider = provider(&oi); + let rt = runtime(); + + let ticket_json = json!({ + "v": "enroll-999", + "api_url": "https://canopy.example.com", + "server_id": "5f2b0d3e-1111-2222-3333-444455556666", + "token": "secret", + }) + .to_string(); + let ticket = rt.block_on(encrypt_ticket(ticket_json.as_bytes(), "correct")); + + let err = rt.block_on(provider.enrol(&ticket, "wrong")).unwrap_err(); + assert!(matches!(err, CanopyError::Decrypt { .. }), "{err}"); + + let err = rt.block_on(provider.enrol(&ticket, "correct")).unwrap_err(); + assert!(matches!(err, CanopyError::InvalidTicket { .. }), "{err}"); +} + +// i[verify canopy.enrol.single] +#[test] +fn enrol_refuses_while_registered() { + let oi = TestOi::new(); + let provider = provider(&oi); + *provider.registration.write() = Some(RegistrationInfo { + server_id: "srv".into(), + device_id: Some("dev".into()), + api_url: "https://canopy.example.com/".into(), + }); + + let err = runtime() + .block_on(provider.enrol("anything", "pass")) + .unwrap_err(); + assert!(matches!(err, CanopyError::AlreadyEnrolled), "{err}"); +} + +// i[verify canopy.status] +#[test] +fn status_surfaces_registration_and_push_state() { + let oi = TestOi::new(); + let provider = provider(&oi); + *provider.registration.write() = Some(RegistrationInfo { + server_id: "srv-1".into(), + device_id: Some("dev-1".into()), + api_url: "https://canopy.example.com/".into(), + }); + *provider.push_status.write() = PushStatus { + last_push_at: Some(Timestamp::now()), + last_push_error: Some("boom".into()), + last_response: Some(json!({ "backup_now": [] })), + }; + + let result = oi.call("/canopy/status", json!({})).unwrap(); + assert_eq!(result["enrolled"], json!(true)); + assert_eq!(result["server_id"], json!("srv-1")); + assert_eq!(result["device_id"], json!("dev-1")); + assert_eq!(result["api_url"], json!("https://canopy.example.com/")); + assert!(result.get("last_push_at").is_some()); + assert_eq!(result["last_push_error"], json!("boom")); + assert_eq!(result["last_response"], json!({ "backup_now": [] })); +} + +// r[verify canopy.registration] +// i[verify canopy.deregister] +#[test] +fn deregister_removes_the_stored_registration() { + let oi = TestOi::new(); + let provider = provider(&oi); + let rt = runtime(); + + let reg = Registration { + server_id: Some("srv-1".into()), + device_id: Some("dev-1".into()), + api_url: Some("https://canopy.example.com/".into()), + device_key: Some("pem".into()), + ..Registration::default() + }; + rt.block_on(registration::store_in(&provider.dir, ®)) + .unwrap(); + // The stored registration survives on disk and round-trips. + let loaded = rt + .block_on(registration::load_from(&provider.dir)) + .unwrap() + .unwrap(); + assert_eq!(loaded.server_id.as_deref(), Some("srv-1")); + + *provider.registration.write() = Some(RegistrationInfo { + server_id: "srv-1".into(), + device_id: Some("dev-1".into()), + api_url: "https://canopy.example.com/".into(), + }); + + assert!(rt.block_on(provider.deregister()).unwrap()); + assert!(provider.registration_info().is_none()); + assert!( + rt.block_on(registration::load_from(&provider.dir)) + .unwrap() + .is_none() + ); + // A second deregister has nothing to remove. + assert!(!rt.block_on(provider.deregister()).unwrap()); +} + +// r[verify canopy.push] +#[test] +fn payload_identifies_seedling_and_covers_the_subsystems() { + let oi = TestOi::new(); + let provider = provider(&oi); + + let payload = runtime().block_on(provider.build_payload()); + assert_eq!(payload["source"], json!("seedling")); + assert_eq!(payload["seedlingVersion"], json!(env!("CARGO_PKG_VERSION"))); + assert!(payload["uptimeSecs"].is_u64()); + + let health = payload["health"].as_array().unwrap(); + let names: Vec<&str> = health + .iter() + .map(|c| c["check"].as_str().unwrap()) + .collect(); + assert_eq!(names, ["proxy", "resolver", "apps"]); + // The stubbed container runtime reports no running containers. + assert_eq!(health[0]["result"], json!("failed")); + assert_eq!(health[1]["result"], json!("failed")); + // No apps registered counts as healthy. + assert_eq!(health[2]["result"], json!("passed")); +} diff --git a/crates/ctl/src/canopy.rs b/crates/ctl/src/canopy.rs new file mode 100644 index 00000000..69ac8b26 --- /dev/null +++ b/crates/ctl/src/canopy.rs @@ -0,0 +1,71 @@ +use std::io::Read as _; + +use clap::Subcommand; +use seedling_protocol::client::OiClient; +use serde_json::json; + +use super::print_result; + +#[derive(Subcommand)] +pub(super) enum CanopyCommand { + /// Enrol this instance with Canopy using an enrolment ticket + // i[impl canopy.enrol] + Enrol { + /// Encrypted enrolment ticket from Canopy. Reads stdin when omitted. + ticket: Option, + /// The ticket's passphrase, shared out of band. Prompted for on + /// stdin when omitted and the ticket was passed as an argument. + #[arg(long)] + passphrase: Option, + }, + /// Show the Canopy link's registration and reporting state + // i[impl canopy.status] + Status, + /// Remove the Canopy registration and stop reporting + // i[impl canopy.deregister] + Deregister, +} + +pub(super) async fn dispatch(client: &OiClient, cmd: CanopyCommand) { + match cmd { + CanopyCommand::Enrol { ticket, passphrase } => { + let ticket = match ticket { + Some(t) => t, + None => { + let mut buf = String::new(); + if std::io::stdin().read_to_string(&mut buf).is_err() || buf.trim().is_empty() { + eprintln!("no ticket on stdin"); + std::process::exit(1); + } + buf.trim().to_owned() + } + }; + let passphrase = match passphrase { + Some(p) => p, + None => { + eprintln!("passphrase:"); + let mut buf = String::new(); + if std::io::stdin().read_line(&mut buf).is_err() || buf.trim().is_empty() { + eprintln!("no passphrase given"); + std::process::exit(1); + } + buf.trim().to_owned() + } + }; + print_result( + client + .request( + "/canopy/enrol", + json!({ "ticket": ticket, "passphrase": passphrase }), + ) + .await, + ); + } + CanopyCommand::Status => { + print_result(client.request("/canopy/status", json!({})).await); + } + CanopyCommand::Deregister => { + print_result(client.request("/canopy/deregister", json!({})).await); + } + } +} diff --git a/crates/ctl/src/main.rs b/crates/ctl/src/main.rs index 3e455d8c..06d9e4ac 100644 --- a/crates/ctl/src/main.rs +++ b/crates/ctl/src/main.rs @@ -10,6 +10,7 @@ use seedling_protocol::{ mod apps; mod backups; +mod canopy; mod client; mod forward; mod ingresses; @@ -132,6 +133,11 @@ enum Command { #[command(subcommand)] command: tls::TlsCommand, }, + /// Canopy fleet-monitoring link (enrolment, reporting status) + Canopy { + #[command(subcommand)] + command: canopy::CanopyCommand, + }, /// User/key management User { #[command(subcommand)] @@ -384,6 +390,7 @@ async fn main() { Command::Backups { command } => backups::dispatch(&client, command).await, Command::Templates { command } => templates::dispatch(&client, command).await, Command::Tls { command } => tls::dispatch(&client, command).await, + Command::Canopy { command } => canopy::dispatch(&client, command).await, Command::User { command } => op::dispatch_user(&client, command).await, Command::Status => { print_result( diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 7d18a846..ae0cbb5d 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -1020,6 +1020,18 @@ async fn main() { // OI server // --------------------------------------------------------------------------- + // r[impl canopy.push] + // Spawn the Canopy reporting provider. It loads any stored registration + // and, while one is present, reports status on its own cadence; an + // unenrolled instance idles silently until the operator enrols via the + // OI, web UI, or CLI. + let canopy_provider = seedling_core::runtime::canopy::CanopyProvider::new( + &data_dir, + Arc::clone(®istry), + Arc::clone(&driver.container), + ); + let _canopy_handle = Arc::clone(&canopy_provider).spawn(); + let oi_state = Arc::new(OiState { registry: Arc::clone(®istry), spki_fingerprint: std::sync::OnceLock::new(), @@ -1042,6 +1054,7 @@ async fn main() { caddy_data_path: tokio::sync::OnceCell::new(), tailscale_provider: Some(Arc::clone(&tailscale_provider)), site_resolver: Some(Arc::clone(&site_resolver)), + canopy_provider: Some(canopy_provider), }); // --------------------------------------------------------------------------- diff --git a/crates/protocol/src/error.rs b/crates/protocol/src/error.rs index abfe8697..e2a48e24 100644 --- a/crates/protocol/src/error.rs +++ b/crates/protocol/src/error.rs @@ -19,6 +19,8 @@ pub enum ErrorCode { Internal, // i[impl backup.app.deregister] BackupAppInUse, + // i[impl canopy.enrol.single] + AlreadyEnrolled, } #[derive(Debug)] @@ -68,6 +70,7 @@ mod tests { (ErrorCode::ServerBusy, "server_busy"), (ErrorCode::Internal, "internal"), (ErrorCode::BackupAppInUse, "backup_app_in_use"), + (ErrorCode::AlreadyEnrolled, "already_enrolled"), ]; for (code, expected) in cases { assert_eq!(serde_json::to_value(code).unwrap(), json!(expected)); diff --git a/crates/web/frontend/src/App.tsx b/crates/web/frontend/src/App.tsx index 6c0647d1..ef75e02e 100644 --- a/crates/web/frontend/src/App.tsx +++ b/crates/web/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { SessionProvider } from "./components/SessionProvider"; import AppDetail from "./routes/AppDetail"; import Apps from "./routes/Apps"; import Backups from "./routes/Backups"; +import Canopy from "./routes/Canopy"; import Certificates from "./routes/Certificates"; import CreateApp from "./routes/CreateApp"; import EditScript from "./routes/EditScript"; @@ -45,6 +46,7 @@ const router = createBrowserRouter([ { path: "backups", element: }, { path: "keys", element: }, { path: "registries", element: }, + { path: "canopy", element: }, { path: "images", element: }, { path: "apps/:name/logs", element: }, { path: "apps/:name/shell/:shellName", element: }, diff --git a/crates/web/frontend/src/components/Navbar.tsx b/crates/web/frontend/src/components/Navbar.tsx index f7047a46..6f7e819d 100644 --- a/crates/web/frontend/src/components/Navbar.tsx +++ b/crates/web/frontend/src/components/Navbar.tsx @@ -1,6 +1,7 @@ import AltRouteIcon from "@mui/icons-material/AltRoute"; import BackupIcon from "@mui/icons-material/Backup"; import CloudQueueIcon from "@mui/icons-material/CloudQueue"; +import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import DescriptionIcon from "@mui/icons-material/Description"; import EventNoteIcon from "@mui/icons-material/EventNote"; import HubIcon from "@mui/icons-material/Hub"; @@ -188,6 +189,16 @@ export function Navbar() { + + + + + { + it("shows the enrol form with a masked passphrase when not enrolled", async () => { + renderWithSession(, { + fixtures: { "/canopy/status": notEnrolled }, + }); + expect(await screen.findByText(/not enrolled with Canopy/)).toBeTruthy(); + expect(screen.getByLabelText("Enrolment ticket")).toBeTruthy(); + const passphrase = screen.getByLabelText("Passphrase"); + expect(passphrase.getAttribute("type")).toBe("password"); + expect(screen.getByRole("button", { name: "Enrol" })).toBeTruthy(); + }); + + it("shows registration details and hides the enrol form when enrolled", async () => { + renderWithSession(, { fixtures: { "/canopy/status": enrolled } }); + expect(await screen.findByText("srv-0195b2")).toBeTruthy(); + expect(screen.getByText("dev-7f3a91")).toBeTruthy(); + expect(screen.getByText("https://canopy.example/api")).toBeTruthy(); + expect( + screen.getByText(new Date(enrolled.last_push_at!).toLocaleString()), + ).toBeTruthy(); + expect(screen.queryByLabelText("Enrolment ticket")).toBeNull(); + expect(screen.queryByLabelText("Passphrase")).toBeNull(); + }); + + it("shows the last report error when the last report failed", async () => { + renderWithSession(, { + fixtures: { + "/canopy/status": { + ...enrolled, + last_push_error: "connection refused", + }, + }, + }); + expect(await screen.findByText("connection refused")).toBeTruthy(); + }); + + it("enrols via /canopy/enrol and clears the ticket and passphrase", async () => { + const { request } = renderWithSession(, { + fixtures: { + "/canopy/status": notEnrolled, + "/canopy/enrol": { server_id: "srv-0195b2", device_id: "dev-7f3a91" }, + }, + safetyMode: "dangerous", + }); + const ticket = await screen.findByLabelText("Enrolment ticket"); + fireEvent.change(ticket, { target: { value: "dGhpcyBpcyBhIHRpY2tldA==" } }); + fireEvent.change(screen.getByLabelText("Passphrase"), { + target: { value: "correct horse battery" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Enrol" })); + await waitFor(() => + expect(request.mock.calls).toContainEqual([ + "/canopy/enrol", + { ticket: "dGhpcyBpcyBhIHRpY2tldA==", passphrase: "correct horse battery" }, + ]), + ); + await waitFor(() => { + expect( + (screen.getByLabelText("Enrolment ticket") as HTMLTextAreaElement).value, + ).toBe(""); + expect( + (screen.getByLabelText("Passphrase") as HTMLInputElement).value, + ).toBe(""); + }); + }); + + it("clears the ticket and passphrase even when enrolment fails", async () => { + renderWithSession(, { + fixtures: { + "/canopy/status": notEnrolled, + "/canopy/enrol": { + ok: false, + error: { code: "requirements_invalid", message: "bad passphrase" }, + }, + }, + safetyMode: "dangerous", + }); + const ticket = await screen.findByLabelText("Enrolment ticket"); + fireEvent.change(ticket, { target: { value: "dGlja2V0" } }); + fireEvent.change(screen.getByLabelText("Passphrase"), { + target: { value: "nope" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Enrol" })); + expect(await screen.findByText(/bad passphrase/)).toBeTruthy(); + expect( + (screen.getByLabelText("Enrolment ticket") as HTMLTextAreaElement).value, + ).toBe(""); + expect((screen.getByLabelText("Passphrase") as HTMLInputElement).value).toBe( + "", + ); + }); + + it("shows an error alert when the status query fails", async () => { + renderWithSession(, { + fixtures: { + "/canopy/status": { + ok: false, + error: { code: "internal", message: "canopy status exploded" }, + }, + }, + }); + expect(await screen.findByText(/canopy status exploded/)).toBeTruthy(); + }); + + it("deregisters via /canopy/deregister only after confirmation", async () => { + const { request } = renderWithSession(, { + fixtures: { + "/canopy/status": enrolled, + "/canopy/deregister": { deregistered: true }, + }, + safetyMode: "dangerous", + }); + fireEvent.click(await screen.findByRole("button", { name: "Deregister" })); + // Opening the confirmation dialog must not itself issue the request. + expect( + request.mock.calls.some(([method]) => method === "/canopy/deregister"), + ).toBe(false); + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "Deregister" })); + await waitFor(() => + expect(request.mock.calls).toContainEqual(["/canopy/deregister", {}]), + ); + }); + + it("keeps enrol disabled in read mode", async () => { + renderWithSession(, { + fixtures: { "/canopy/status": notEnrolled }, + }); + const ticket = await screen.findByLabelText("Enrolment ticket"); + fireEvent.change(ticket, { target: { value: "dGlja2V0" } }); + fireEvent.change(screen.getByLabelText("Passphrase"), { + target: { value: "pass" }, + }); + expect(screen.getByRole("button", { name: "Enrol" })).toHaveProperty( + "disabled", + true, + ); + }); +}); diff --git a/crates/web/frontend/src/routes/Canopy.tsx b/crates/web/frontend/src/routes/Canopy.tsx new file mode 100644 index 00000000..74f05374 --- /dev/null +++ b/crates/web/frontend/src/routes/Canopy.tsx @@ -0,0 +1,235 @@ +import LinkOffIcon from "@mui/icons-material/LinkOff"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { useState } from "react"; +import { + IconActionButton, + SolidActionButton, +} from "../components/ActionButton"; +import { OiErrorAlert } from "../components/OiErrorAlert"; +import { useOiQuery } from "../hooks/useOi"; +import { useOiAction } from "../hooks/useOiAction"; +import type { CanopyStatus } from "../lib/types"; + +// w[impl routes.canopy] +export default function Canopy() { + const { data, loading, error, refetch } = + useOiQuery("/canopy/status", {}); + const { execute, loading: mutating, error: mutateError, clearError } = + useOiAction(); + + const [ticket, setTicket] = useState(""); + const [passphrase, setPassphrase] = useState(""); + const [confirmingDeregister, setConfirmingDeregister] = useState(false); + + const submitEnrol = async () => { + const result = await execute("/canopy/enrol", { + ticket: ticket.trim(), + passphrase, + }); + // Neither the ticket nor the passphrase is retained client-side after + // submission — clear both whether or not the enrolment succeeded. + setTicket(""); + setPassphrase(""); + if (result === null) return; + refetch(); + }; + + const submitDeregister = async () => { + if ((await execute("/canopy/deregister", {})) === null) return; + setConfirmingDeregister(false); + refetch(); + }; + + return ( + + + + Canopy + + + + + {data?.enrolled && ( + } + onClick={() => { + clearError(); + setConfirmingDeregister(true); + }} + > + Deregister + + )} + + + Canopy is a fleet monitoring service. An enrolled seedling reports its + status to the Canopy server it is registered with. + + {error && } + {loading && !data && ( + + + + )} + {data && !data.enrolled && ( + + + This seedling is not enrolled with Canopy. Paste an enrolment + ticket issued by your Canopy server, together with its passphrase, + to begin reporting. + + setTicket(e.target.value)} + slotProps={{ + htmlInput: { style: { fontFamily: "monospace", fontSize: "0.8rem" } }, + }} + /> + setPassphrase(e.target.value)} + helperText="The ticket and passphrase are sent once and not retained in the browser." + /> + {mutateError && } + + + Enrol + + + + )} + {data?.enrolled && ( + + + + + + Server ID + + {data.server_id} + + + + Device ID + + {data.device_id} + + + + API URL + + {data.api_url} + + + + Last report + + {data.last_push_at + ? new Date(data.last_push_at).toLocaleString() + : "No reports attempted yet"} + + + {data.last_push_error && ( + + Last report error + + {data.last_push_error} + + + )} + +
+
+ {data.last_response !== undefined && ( +
+ + Last response from Canopy + + + {JSON.stringify(data.last_response, null, 2)} + +
+ )} + {mutateError && !confirmingDeregister && } +
+ )} + setConfirmingDeregister(false)} + fullWidth + maxWidth="sm" + > + Deregister from Canopy + + + + Deregister this seedling from Canopy? It will stop reporting and + its device credentials will be discarded. Re-enrolling requires a + new enrolment ticket. + + {mutateError && } + + + + + + Deregister + + + +
+ ); +} diff --git a/docs/deploying.md b/docs/deploying.md index 85aa0fc0..b7977757 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -176,3 +176,19 @@ verifiable with `gh attestation verify -R beyondessential/seedling`. Running the daemon from a tarball means creating the state directories, the systemd unit, and the log rotation yourself; the package is the supported way to run the daemon. + +## Canopy fleet monitoring + +An enrolled instance reports its status (proxy, resolver, and app health) to +Canopy every minute. Create the server record in Canopy first; it hands back +an encrypted enrolment ticket plus a passphrase (shared out of band), then: + +``` +seedling-ctl canopy enrol --passphrase +seedling-ctl canopy status +``` + +The same flow is available on the web interface's Canopy page. Deregister with +`seedling-ctl canopy deregister`; the registration lives encrypted under +`/canopy/` and is bound to the machine, so restoring it onto another +host requires a fresh enrolment. diff --git a/docs/spec/interface.md b/docs/spec/interface.md index b195f200..0fa6a3de 100644 --- a/docs/spec/interface.md +++ b/docs/spec/interface.md @@ -153,6 +153,7 @@ Absent specification bugs, anything that is not defined here is either defined i > | `server_busy` | The server's stream concurrency limit has been reached; the client should retry after a delay. | > | `internal` | An unexpected server-side failure (for example a database error) prevented the request from completing. | > | `backup_app_in_use` | The backup app cannot be deregistered because a backup strategy still references it. | +> | `already_enrolled` | `/canopy/enrol` was called but this instance already holds a Canopy registration. | # Status @@ -1114,6 +1115,37 @@ This section covers the operator interface for the ACME-DNS strategy, manual cer > `cert_profile` (string or null) opts into a non-default ACME profile (e.g. Let's Encrypt's `shortlived` for ~6-day certs) per [tls.settings.cert-profile](runtime.md#r--tls.settings.cert-profile); pass `null` to clear so the CA selects its default profile, or omit the field to leave it unchanged. > Both updates take effect on the next renewal pass without daemon restart. +# Canopy Reporting + +Seedling instances can report their status to Canopy, the fleet monitoring service. +The OI exposes the enrolment lifecycle; the reporting behaviour itself is defined in the runtime spec (see [canopy.push](runtime.md#r--canopy.push)). + +> i[canopy.enrol] +> `/canopy/enrol { ticket, passphrase }` enrols this Seedling instance with Canopy. +> `ticket` is the encrypted enrolment ticket issued by Canopy when the operator creates the server record there, and `passphrase` is the accompanying secret shared out of band. +> Enrolment mints a fresh device identity, proves possession of it to Canopy, and persists the resulting registration (see [canopy.registration](runtime.md#r--canopy.registration)). +> On success the response is `{ server_id, device_id }`, the identities Canopy assigned. +> A ticket that is not a valid Canopy enrolment ticket, a wrong passphrase, or a rejection from Canopy is reported as `requirements_invalid` with detail in `message`; the enrolment token is not consumed by a failed decrypt. + +> i[canopy.enrol.single] +> An instance holds at most one Canopy registration. +> `/canopy/enrol` is refused with `already_enrolled` while a registration is present; the operator must deregister first. +> A fresh enrolment never reuses identity material from a previous registration. + +> i[canopy.status] +> `/canopy/status` returns the state of the Canopy link: +> +> - `enrolled`: whether a registration is present. +> - `server_id`, `device_id`, `api_url`: the stored registration's identities and endpoint; absent when not enrolled. +> - `last_push_at`: timestamp (RFC 3339) of the most recent report attempt; absent before the first attempt. +> - `last_push_error`: the most recent report failure; absent when the last report succeeded. +> - `last_response`: the response Canopy returned for the most recent successful report; absent before the first success. + +> i[canopy.deregister] +> `/canopy/deregister` removes the stored registration and stops status reporting. +> It returns `{ deregistered }`, `true` when a registration was removed and `false` when none was present. +> Deregistering does not notify Canopy; the server record there is managed by the operator. + # Client Behaviour > i[ctl.graceful-shutdown] diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 1dfcceec..dfb18c02 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -1531,3 +1531,27 @@ The BSL surface is intentionally strategy-agnostic: scripts declare only that an > r[tls.policy.apply] > Changes to operator-defined TLS policy (binding a hostname to a strategy, changing its parameters, or clearing it) must take effect on a subsequent reconciliation tick without operator-initiated apply steps. > The runtime must rebuild the proxy configuration accordingly. + +# Canopy Reporting + +An enrolled Seedling instance periodically reports its status to Canopy, the fleet monitoring service, so operators can observe the fleet without connecting to each host. +Enrolment is operator-initiated through the OI (see [canopy.enrol](interface.md#i--canopy.enrol)), the web interface, or the CLI. + +> r[canopy.registration] +> A Canopy registration binds this instance to a server record the operator pre-created in Canopy. +> Enrolment decrypts the operator-supplied ticket with its passphrase, mints a fresh device key, and claims the server record by proving possession of that key to Canopy. +> The resulting registration — the device key together with the identities and endpoint Canopy assigned — is persisted in the data directory, encrypted at rest and bound to the machine so a copied registration file is useless elsewhere. +> The device key is the instance's identity to Canopy and must never appear in logs or OI responses. + +> r[canopy.push] +> While a registration is present, the runtime reports status to Canopy at a regular interval (default one minute). +> Each report identifies this instance as the `seedling` source and carries the Seedling version, the host name, the daemon uptime, and a health section covering at minimum: the reverse proxy, the DNS resolver, and the managed apps (passing when every registered app is running, a warning when some are not). +> An instance with no registration reports nothing and files no faults about reporting. + +> r[canopy.push.response] +> The response Canopy returns to a report is logged and retained for the OI status surface (see [canopy.status](interface.md#i--canopy.status)). +> The runtime does not yet act on response contents; it is recorded so operators can verify the channel end to end. + +> r[canopy.push.fault] +> A failed report is logged as a warning and reflected in the OI status surface. +> After five consecutive failures the runtime escalates to logging at error level; the escalation resets on the next successful report. diff --git a/docs/spec/web.md b/docs/spec/web.md index 5798cfc9..0746a6e1 100644 --- a/docs/spec/web.md +++ b/docs/spec/web.md @@ -240,6 +240,11 @@ Absent specification bugs, anything not defined here is either defined in anothe > > Private key material, ACME account keys, and DNS-provider credentials must never be returned to the client. +> w[routes.canopy] +> The web interface exposes the Canopy link at `/canopy`: showing the current registration state and reporting health (via [`/canopy/status`](interface.md#i--canopy.status)); enrolling by pasting the enrolment ticket and its passphrase; and deregistering. +> The passphrase field must be masked, and neither the ticket nor the passphrase is retained client-side after submission. +> Deregistering is a destructive action and must be confirmed before the request is issued. + > w[sessions.events] > The web interface must emit `WebSessionStarted`, `WebSessionStopped`, and `WebSessionModeChanged` events on the event feed when a WebTransport session is established, closed, or reports a new [safety mode](#w--sessions.safety-mode). Clients must use these events, together with the OI events `ShellStarted`, `ShellExited`, `ForwardStarted`, and `ForwardStopped`, to keep the connected-clients view up to date without polling.