diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f292b4..8bedb0b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: 1.91 + toolchain: 1.97.1 components: clippy,rustfmt - name: Checkout @@ -40,11 +40,16 @@ jobs: - name: Build run: cargo build --locked + - name: Install nextest + uses: taiki-e/install-action@nextest + - name: Test - run: cargo test + run: | + cargo nextest run --run-ignored all && + cargo test --doc - # The permslip feature pulls in permission-slip, which is private, so - # this job only runs where the OIDC exchange can succeed. + # The permslip feature pulls in permission-slip, which is private, + # so this job only runs where the OIDC exchange can succeed. Permslip: if: github.repository == 'oxidecomputer/sush' runs-on: ubuntu-22.04 @@ -55,7 +60,7 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: 1.91 + toolchain: 1.97.1 components: clippy - name: Checkout diff --git a/Cargo.lock b/Cargo.lock index ae549ab..1c4f3bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,11 +2,38 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -19,18 +46,18 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -43,15 +70,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -78,24 +105,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" - -[[package]] -name = "ar_archive_writer" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" -dependencies = [ - "object", -] - -[[package]] -name = "archery" -version = "1.2.2" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "array-init" @@ -114,9 +126,21 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-compression" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] [[package]] name = "async-recursion" @@ -126,7 +150,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -148,18 +172,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 3.0.3", ] [[package]] @@ -177,23 +201,219 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attest-data" +version = "0.5.0" +source = "git+https://github.com/oxidecomputer/dice-util?rev=10952e8d9599b735b85d480af3560a11700e5b64#10952e8d9599b735b85d480af3560a11700e5b64" +dependencies = [ + "const-oid 0.9.6", + "der", + "getrandom 0.3.4", + "hex", + "hubpack", + "rats-corim", + "salty", + "serde", + "serde_with", + "sha3", + "static_assertions", + "thiserror 2.0.20", +] + +[[package]] +name = "attest-data" +version = "0.5.0" +source = "git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d#4a39ef08d81e5177edee0bddb1146032aa21074d" +dependencies = [ + "const-oid 0.9.6", + "der", + "getrandom 0.4.3", + "hex", + "hubpack", + "rats-corim", + "salty", + "serde", + "serde_with", + "sha3", + "static_assertions", + "thiserror 2.0.20", +] + +[[package]] +name = "attest-mock" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/dice-util?rev=10952e8d9599b735b85d480af3560a11700e5b64#10952e8d9599b735b85d480af3560a11700e5b64" +dependencies = [ + "anyhow", + "attest-data 0.5.0 (git+https://github.com/oxidecomputer/dice-util?rev=10952e8d9599b735b85d480af3560a11700e5b64)", + "clap", + "hex", + "hubpack", + "knuffel", + "miette 5.10.0", + "rats-corim", + "serde_json", +] + [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] -name = "base16ct" +name = "aws-credential-types" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.5.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] [[package]] -name = "base64" -version = "0.13.1" +name = "aws-smithy-types" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "http 0.2.12", + "http 1.5.0", + "http-body 0.4.6", + "http-body 1.1.0", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", +] + +[[package]] +name = "aws-types" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "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 = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" @@ -207,42 +427,55 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "before" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/rumors?rev=748325407feb4c61f558713bad431281f8326ac4#748325407feb4c61f558713bad431281f8326ac4" +source = "git+https://github.com/oxidecomputer/rumors?rev=66aea68b6578345275de3cf14151c4707345773f#66aea68b6578345275de3cf14151c4707345773f" dependencies = [ "bitvec", "borsh", - "num-bigint", - "stacker", - "thiserror 2.0.18", + "bytes", + "dashu-int", + "dsi-bitstream", + "static_assertions", + "suanpan", + "thiserror 2.0.20", ] [[package]] name = "bitfield" -version = "0.19.4" +version = "0.19.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +checksum = "b45721c9db4c7a20899d05efb7ad9235f50b256e980db30ffb229abf732934c3" dependencies = [ "bitfield-macros", ] [[package]] name = "bitfield-macros" -version = "0.19.4" +version = "0.19.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +checksum = "c0cb6f3d4773a2107b94cbeccaa5b5f0b35a88389b5d522d13d659f64317b22d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 3.0.3", ] [[package]] @@ -253,21 +486,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bitmaps" -version = "3.2.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -277,15 +504,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.2" +version = "1.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", + "cpufeatures 0.3.0", "memmap2", "rayon-core", ] @@ -299,11 +527,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borsh" -version = "1.6.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" dependencies = [ "borsh-derive", "bytes", @@ -312,28 +549,31 @@ dependencies = [ [[package]] name = "borsh-derive" -version = "1.6.1" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" dependencies = [ "once_cell", "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] -name = "bumpalo" -version = "3.19.0" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] [[package]] -name = "bytemuck" -version = "1.25.2" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -343,33 +583,45 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] [[package]] name = "bytesize" -version = "2.3.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" +checksum = "7354288c522e7e980fafd2075d63d1285794c3a6a16cdd492f189ea406e5f18b" [[package]] name = "camino" -version = "1.2.1" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ "serde_core", ] [[package]] name = "cc" -version = "1.2.45" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", - "shlex", + "jobserver", + "libc", + "shlex 2.0.1", ] [[package]] @@ -380,22 +632,69 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", +] + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", ] [[package]] @@ -404,15 +703,15 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] [[package]] name = "clap" -version = "4.5.53" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -429,34 +728,34 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", - "terminal_size", + "terminal_size 0.4.4", ] [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.115", + "syn 3.0.3", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" @@ -467,31 +766,66 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cobs" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "console" -version = "0.16.2" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03e45a4a8926227e4197636ba97a9fc9b00477e9f4bd711395687c5f0734bec4" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -501,11 +835,17 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "core-foundation" @@ -517,12 +857,28 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "corncobs" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e03e9489176ebd301922fdcd0234f6dee954cbf65311863353f20d2746a8db" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -532,11 +888,29 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc-any" -version = "2.5.0" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46db9f663dfb869b80fcf59e32d7a80fc6c464a4f6328f3f06a00f5e36d05f8c" + +[[package]] +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62ec9ff5f7965e4d7280bd5482acd20aadb50d632cf6c1d74493856b011fa73" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] [[package]] name = "critical-section" @@ -546,18 +920,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -565,18 +939,24 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-bigint" @@ -594,10 +974,19 @@ dependencies = [ name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", ] [[package]] @@ -607,9 +996,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -624,14 +1013,14 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "daft" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49921a57f45e3bf2cc8a0c4e3a10aa342b95a481d7dd89d844c1225496957296" +checksum = "37da1a58f7d13865d88632f596075dab24f01df73390b011d93742db7f6229a1" dependencies = [ "daft-derive", "newtype-uuid", @@ -642,20 +1031,73 @@ dependencies = [ [[package]] name = "daft-derive" -version = "0.1.7" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c839348835bc8a59714d79797f2f1bca237ca043e3d2d5ce4e727606396fb834" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91850c0efee1e6dffcea4e5841ff3a71db80575a79f933d0c77e41c6fe0973d1" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ + "ident_case", "proc-macro2", "quote", - "syn 2.0.115", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashu-base" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64b04cdfc4c8533100fe00304eb9687173bda47b1f1dac8af12ba13712ed49d" + +[[package]] +name = "dashu-int" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ee98721d5d223e5b64b642dd9588b79d9ef415554b13720308b77d628c3be6" +dependencies = [ + "cfg-if", + "dashu-base", + "num-modular", + "rustversion", + "static_assertions", ] [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "debug-ignore" @@ -663,13 +1105,44 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe7ed1d93f4553003e20b629abe9085e1e81b1429520f897f8f8860bc6dfc21" +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der_derive", "flagset", "pem-rfc7468", @@ -684,16 +1157,66 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", + "serde_core", +] + +[[package]] +name = "dice-mfg-msgs" +version = "0.3.0" +source = "git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d#4a39ef08d81e5177edee0bddb1146032aa21074d" +dependencies = [ + "const-oid 0.9.6", + "corncobs", + "dice-util-barcode", + "hubpack", + "serde", + "serde-big-array", + "thiserror 2.0.20", + "x509-cert", + "zerocopy", +] + +[[package]] +name = "dice-util-barcode" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d#4a39ef08d81e5177edee0bddb1146032aa21074d" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "dice-verifier" +version = "0.3.0-pre0" +source = "git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d#4a39ef08d81e5177edee0bddb1146032aa21074d" +dependencies = [ + "anyhow", + "async-trait", + "attest-data 0.5.0 (git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d)", + "camino", + "const-oid 0.9.6", + "ed25519-dalek", + "env_logger", + "hex", + "hubpack", + "log", + "p384", + "pki-playground 0.2.0 (git+https://github.com/oxidecomputer/pki-playground?rev=bcd3bf2dfe36468c494eac17463a4e10be366b35)", + "rats-corim", + "sha3", + "slog", + "tempfile", + "thiserror 2.0.20", + "tokio", + "x509-cert", ] [[package]] @@ -702,29 +1225,41 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 3.0.3", ] [[package]] name = "dropshot" -version = "0.16.4" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd9bdeafc752f117ed20e659b9763695ae5900adf3a32e93f9f6f4052fd5d66" +checksum = "803c4a57fd2a611df2ddd7069a62a565253368ee96ad4e5ac2e74e625dcf8430" dependencies = [ + "async-compression", "async-stream", "async-trait", "base64 0.22.1", @@ -735,35 +1270,36 @@ dependencies = [ "dropshot_endpoint", "form_urlencoded", "futures", - "hostname 0.4.1", - "http 1.4.0", + "hostname 0.4.2", + "http 1.5.0", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", - "indexmap 2.13.0", + "indexmap 2.14.0", "multer", "openapiv3", "paste", "percent-encoding", - "rustls 0.22.4", - "rustls-pemfile 2.2.0", - "schemars", + "rustls", + "rustls-pemfile", + "schemars 0.8.22", "scopeguard", "semver", "serde", "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", + "sha1 0.11.0", "slog", "slog-async", "slog-bunyan", "slog-json", "slog-term", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", - "tokio-rustls 0.25.0", - "toml", + "tokio-rustls", + "tokio-util", + "toml 1.1.4+spec-1.1.0", "uuid", "version_check", "waitgroup", @@ -771,19 +1307,34 @@ dependencies = [ [[package]] name = "dropshot_endpoint" -version = "0.16.4" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d09440e73a9dcf8a0f7fbd6ab889a7751d59f0fe76e5082a0a6d5623ec6da3" +checksum = "5f176851eb52822c728ad7a1c5aea0e3c95e68d9876cfa06ae32d5018730acb4" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "semver", "serde", "serde_tokenstream", - "syn 2.0.115", + "syn 2.0.119", +] + +[[package]] +name = "dsi-bitstream" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f3e8a7ab0f1148cf7a792807d330f1e417dda3f4f122526e609a83925b4823" +dependencies = [ + "num-primitive", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -797,7 +1348,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -825,15 +1376,16 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2", + "signature", "subtle", "zeroize", ] [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "elliptic-curve" @@ -843,10 +1395,11 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core 0.6.4", @@ -890,18 +1443,19 @@ checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" [[package]] name = "env_filter" -version = "0.1.4" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", + "regex", ] [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -942,9 +1496,9 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fd-lock" @@ -954,7 +1508,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -975,9 +1529,9 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "flagset" @@ -985,6 +1539,16 @@ version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -997,21 +1561,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1021,6 +1570,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "function_name" version = "0.3.0" @@ -1044,9 +1599,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" dependencies = [ "futures-channel", "futures-core", @@ -1059,9 +1614,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", "futures-sink", @@ -1069,15 +1624,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" dependencies = [ "futures-core", "futures-task", @@ -1086,38 +1641,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-channel", "futures-core", @@ -1127,7 +1682,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1144,9 +1698,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1163,10 +1717,30 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "group" version = "0.13.0" @@ -1180,17 +1754,17 @@ dependencies = [ [[package]] name = "h2" -version = "0.3.27" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ + "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.13.0", + "http 1.5.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1198,22 +1772,14 @@ dependencies = [ ] [[package]] -name = "h2" -version = "0.4.12" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.0", - "indexmap 2.13.0", - "slab", - "tokio", - "tokio-util", - "tracing", + "cfg-if", + "crunchy", + "zerocopy", ] [[package]] @@ -1231,6 +1797,16 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1244,9 +1820,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", @@ -1267,6 +1843,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.5.0" @@ -1285,13 +1870,22 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1316,13 +1910,13 @@ dependencies = [ [[package]] name = "hostname" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ "cfg-if", "libc", - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -1338,9 +1932,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1359,24 +1953,24 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.4.0", + "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "pin-project-lite", ] @@ -1398,126 +1992,100 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hubpack" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a0b84aeae519f65e0ba3aa998327080993426024edbd5cc38dbaf5ec524303" +dependencies = [ + "hubpack_derive", + "serde", +] + +[[package]] +name = "hubpack_derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f928320aff16ee8818ef7309180f8b5897057fd79d9dcb8de3ed1ba6dcc125a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] -name = "hyper" -version = "0.14.32" +name = "hybrid-array" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", + "typenum", ] [[package]] name = "hyper" -version = "1.8.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.12", - "http 1.4.0", - "http-body 1.0.1", + "h2", + "http 1.5.0", + "http-body 1.1.0", "httparse", "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec 1.15.1", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "rustls 0.21.12", + "itoa", + "pin-project-lite", + "smallvec 1.15.2", "tokio", - "tokio-rustls 0.24.1", + "want", ] [[package]] name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http 1.4.0", - "hyper 1.8.1", - "hyper-util", - "rustls 0.23.35", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.4", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "bytes", - "http-body-util", - "hyper 1.8.1", + "http 1.5.0", + "hyper", "hyper-util", - "native-tls", + "rustls", "tokio", - "tokio-native-tls", + "tokio-rustls", "tower-service", ] [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.8.1", + "http 1.5.0", + "http-body 1.1.0", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", - "system-configuration 0.6.1", + "socket2", + "system-configuration", "tokio", + "tower-layer", "tower-service", "tracing", "windows-registry", @@ -1525,9 +2093,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1549,12 +2117,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1562,9 +2131,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1575,29 +2144,29 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", - "smallvec 1.15.1", + "smallvec 1.15.2", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1609,15 +2178,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1628,6 +2197,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1635,45 +2210,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", - "smallvec 1.15.1", + "smallvec 1.15.2", "utf8_iter", ] [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] -[[package]] -name = "imbl" -version = "7.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43ea8d4c37ee560727e824d62804183624d371e632019b0e9e3532bce64a33e5" -dependencies = [ - "archery", - "bitmaps", - "equivalent", - "imbl-sized-chunks", - "rand_core 0.9.5", - "rand_xoshiro", - "version_check", - "wide", -] - -[[package]] -name = "imbl-sized-chunks" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" -dependencies = [ - "bitmaps", -] - [[package]] name = "indexmap" version = "1.9.3" @@ -1687,25 +2237,25 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] [[package]] name = "indicatif" -version = "0.18.3" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", "portable-atomic", - "unicode-width", + "unicode-width 0.2.2", "unit-prefix", "web-time", ] @@ -1721,9 +2271,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "ipnetwork" @@ -1731,16 +2281,6 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" -[[package]] -name = "iri-string" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-terminal" version = "0.4.17" @@ -1752,6 +2292,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1769,20 +2315,196 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "knuffel" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04bee6ddc6071011314b1ce4f7705fef6c009401dba4fd22cb0009db6a177413" +dependencies = [ + "base64 0.21.7", + "chumsky", + "knuffel-derive", + "miette 5.10.0", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + +[[package]] +name = "knuffel-derive" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91977f56c49cfb961e3d840e2e7c6e4a56bde7283898cf606861f1421348283d" +dependencies = [ + "heck 0.4.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "knus" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6a53d1efe403777ea7ad6dcefb498c2c09f53591c361ca175e2adb050bf95e" +dependencies = [ + "base64 0.22.1", + "chumsky", + "knus-derive", + "miette 7.6.0", + "thiserror 2.0.20", + "unicode-width 0.2.2", +] + +[[package]] +name = "knus-derive" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "268277f3967db51921dd2eb97256e533d718584e77b7ab1b0b72d1b6103f5a60" +dependencies = [ + "heck 0.5.0", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1794,27 +2516,27 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -1827,14 +2549,14 @@ 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 = "lpc55_areas" version = "0.2.5" -source = "git+https://github.com/oxidecomputer/lpc55_support#205d47d497aa59985136b3c5eddaa0a39da203a2" +source = "git+https://github.com/oxidecomputer/lpc55_support#fc64732faf5511850b35f1734d572b9953d73374" dependencies = [ "bitfield", "clap", @@ -1845,10 +2567,10 @@ dependencies = [ [[package]] name = "lpc55_sign" version = "0.3.5" -source = "git+https://github.com/oxidecomputer/lpc55_support#205d47d497aa59985136b3c5eddaa0a39da203a2" +source = "git+https://github.com/oxidecomputer/lpc55_support#fc64732faf5511850b35f1734d572b9953d73374" dependencies = [ "byteorder", - "const-oid", + "const-oid 0.9.6", "crc-any", "der", "env_logger", @@ -1862,20 +2584,26 @@ dependencies = [ "serde", "serde-hex", "sha2", - "thiserror 2.0.18", + "thiserror 2.0.20", "x509-cert", "zerocopy", ] [[package]] name = "lru" -version = "0.18.0" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ - "hashbrown 0.17.0", + "hashbrown 0.17.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "match_cfg" version = "0.1.0" @@ -1890,30 +2618,102 @@ checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "backtrace", + "backtrace-ext", + "is-terminal", + "miette-derive 5.10.0", + "once_cell", + "owo-colors 3.5.0", + "supports-color 2.1.0", + "supports-hyperlinks 2.1.0", + "supports-unicode 2.1.0", + "terminal_size 0.1.17", + "textwrap 0.15.2", + "thiserror 1.0.69", + "unicode-width 0.1.14", +] + +[[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 7.6.0", + "owo-colors 4.3.0", + "supports-color 3.0.2", + "supports-hyperlinks 3.2.0", + "supports-unicode 3.0.0", + "terminal_size 0.4.4", + "textwrap 0.16.2", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", +] + [[package]] name = "mime" version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.1.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -1929,7 +2729,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http 1.5.0", "httparse", "memchr", "mime", @@ -1937,23 +2737,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "natord" version = "1.0.9" @@ -1962,9 +2745,9 @@ checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" [[package]] name = "newtype-uuid" -version = "1.3.2" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c012d14ef788ab066a347d19e3dda699916c92293b05b85ba2c76b8c82d2830" +checksum = "0fa56da58097c7ae893c4097bb6d91bb5f7fa330053e07e033349db4d5f83ba8" dependencies = [ "uuid", ] @@ -1975,7 +2758,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" dependencies = [ - "smallvec 1.15.1", + "smallvec 1.15.2", ] [[package]] @@ -1984,7 +2767,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -1996,16 +2779,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2017,17 +2790,17 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.7", "serde", - "smallvec 1.15.1", + "smallvec 1.15.2", "zeroize", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2040,15 +2813,26 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44" + +[[package]] +name = "num-primitive" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0178502a58a2514f927965f80216c0ea2533b5364061544ea8e09b79343d4183" + [[package]] name = "num-traits" version = "0.2.19" @@ -2070,16 +2854,15 @@ dependencies = [ [[package]] name = "oauth2" -version = "4.4.2" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.13.1", + "base64 0.22.1", "chrono", - "getrandom 0.2.16", - "http 0.2.12", - "rand 0.8.5", - "reqwest 0.11.27", + "getrandom 0.2.17", + "http 1.5.0", + "rand 0.8.7", "serde", "serde_json", "serde_path_to_error", @@ -2088,6 +2871,16 @@ dependencies = [ "url", ] +[[package]] +name = "oauth2-reqwest" +version = "0.1.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234fb5c965bbce983ee5de636a7a51d6a3223da8067ea02f9ab2d2d78ac08be2" +dependencies = [ + "oauth2", + "reqwest", +] + [[package]] name = "object" version = "0.37.3" @@ -2105,9 +2898,9 @@ checksum = "e22821954cca73148cdd1547a540ac79a3f27b6d55b518490437bb9867212828" [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2121,54 +2914,34 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", "serde_json", ] -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "outref" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] -name = "openssl-probe" -version = "0.1.6" +name = "owo-colors" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" [[package]] -name = "openssl-sys" -version = "0.9.111" +name = "owo-colors" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "oxnet" @@ -2177,7 +2950,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a1d55676fd6e2fe06971c05d176c7aca3d2b7d6ed1244ae8026beb1e7b89058" dependencies = [ "ipnetwork", - "schemars", + "schemars 0.8.22", "serde", "serde_json", ] @@ -2261,8 +3034,8 @@ dependencies = [ "cfg-if", "libc", "redox_syscall", - "smallvec 1.15.1", - "windows-link 0.2.1", + "smallvec 1.15.2", + "windows-link", ] [[package]] @@ -2289,7 +3062,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "permission-slip-client" version = "1.1.0" -source = "git+https://github.com/oxidecomputer/permission-slip#b8dfbcba8cd5d159cfe0167142771d2e5acd73e7" +source = "git+https://github.com/oxidecomputer/permission-slip#11f249d93ab0c539febabda9da67cc2dc6aa5355" dependencies = [ "anyhow", "base64 0.22.1", @@ -2299,16 +3072,18 @@ dependencies = [ "clap-num", "der", "ed25519-dalek", + "futures", "hex", "lpc55_sign", "natord", "oauth2", + "oauth2-reqwest", "once_cell", "permission-slip-common", "progenitor", "progenitor-client", "rand_core 0.6.4", - "reqwest 0.12.24", + "reqwest", "serde", "serde_json", "ssh-key", @@ -2321,22 +3096,24 @@ dependencies = [ [[package]] name = "permission-slip-common" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/permission-slip#b8dfbcba8cd5d159cfe0167142771d2e5acd73e7" +source = "git+https://github.com/oxidecomputer/permission-slip#11f249d93ab0c539febabda9da67cc2dc6aa5355" dependencies = [ + "aws-types", "blake3", "clap", - "schemars", + "der", + "schemars 0.8.22", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.20", "x509-cert", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2367,21 +3144,84 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +name = "pki-playground" +version = "0.2.0" +source = "git+https://github.com/oxidecomputer/pki-playground?rev=7600756029ce046a02c6234aa84ce230cc5eaa04#7600756029ce046a02c6234aa84ce230cc5eaa04" +dependencies = [ + "camino", + "clap", + "const-oid 0.9.6", + "der", + "digest 0.10.7", + "ed25519-dalek", + "flagset", + "hex", + "ipnet", + "knuffel", + "miette 5.10.0", + "p384", + "pem-rfc7468", + "pkcs8", + "rand 0.8.7", + "rsa", + "sha1 0.10.7", + "sha2", + "sha3", + "signature", + "spki", + "x509-cert", + "zeroize", +] + +[[package]] +name = "pki-playground" +version = "0.2.0" +source = "git+https://github.com/oxidecomputer/pki-playground?rev=bcd3bf2dfe36468c494eac17463a4e10be366b35#bcd3bf2dfe36468c494eac17463a4e10be366b35" +dependencies = [ + "camino", + "clap", + "const-oid 0.9.6", + "der", + "digest 0.10.7", + "ed25519-dalek", + "flagset", + "hex", + "ipnet", + "knus", + "miette 7.6.0", + "p384", + "pem-rfc7468", + "pkcs8", + "rand 0.8.7", + "rsa", + "sha1 0.10.7", + "sha2", + "sha3", + "signature", + "spki", + "x509-cert", + "zeroize", +] [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] [[package]] name = "postcard" @@ -2398,9 +3238,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2431,27 +3271,73 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ - "toml_edit", + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "progenitor" -version = "0.11.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2326f73d5326257514712436680ef8da4543ee47c0e9e0d501545c8909ee12e4" +checksum = "d8ba1d77160e6d5c95bdf0792527f76bf528791093fa83015bc2908a0ba9d076" dependencies = [ "progenitor-client", "progenitor-impl", @@ -2460,14 +3346,14 @@ dependencies = [ [[package]] name = "progenitor-client" -version = "0.11.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71a0beb939758f229cbae70a4889c7c76a4ac0e90f0b1e7ae9b4636a927d1018" +checksum = "4e8a874cf25a33cac7a01b9c1de87bcfbc8aea93f3156d09dcc3bee516a78926" dependencies = [ "bytes", "futures-core", "percent-encoding", - "reqwest 0.12.24", + "reqwest", "serde", "serde_json", "serde_urlencoded", @@ -2475,69 +3361,116 @@ dependencies = [ [[package]] name = "progenitor-impl" -version = "0.11.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f6d9109b04e005bbdec84cacec7e81cc15533f2b5dc505f0defc212d270c15" +checksum = "f6e349eed84b9a1a6a5dbe478d335e3df73d32a93c5eefe571c9b8cb298aab5d" dependencies = [ - "heck", - "http 1.4.0", - "indexmap 2.13.0", + "heck 0.5.0", + "http 1.5.0", + "indexmap 2.14.0", "openapiv3", "proc-macro2", "quote", "regex", - "schemars", + "schemars 0.8.22", "serde", "serde_json", - "syn 2.0.115", - "thiserror 2.0.18", + "syn 2.0.119", + "thiserror 2.0.20", "typify", "unicode-ident", ] [[package]] name = "progenitor-macro" -version = "0.11.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46596c574831739c661f22923fe587399c61f5e3e79b73cc9a93644c72248d84" +checksum = "efa969a1349979c5f64347f204e794781a86d738206d75672e6c9493f5910002" dependencies = [ "openapiv3", "proc-macro2", "progenitor-impl", "quote", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_tokenstream", "serde_yaml", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] -name = "psm" -version = "0.1.31" +name = "pwd" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +checksum = "72c71c0c79b9701efe4e1e4b563b2016dd4ee789eb99badcb09d61ac4b92e4a2" dependencies = [ - "ar_archive_writer", - "cc", + "libc", + "thiserror 1.0.69", ] [[package]] -name = "pwd" -version = "1.4.0" +name = "quinn" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72c71c0c79b9701efe4e1e4b563b2016dd4ee789eb99badcb09d61ac4b92e4a2" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ + "cfg_aliases", "libc", - "thiserror 1.0.69", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2548,6 +3481,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -2566,9 +3505,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -2577,14 +3516,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2611,7 +3561,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -2624,12 +3574,33 @@ dependencies = [ ] [[package]] -name = "rand_xoshiro" -version = "0.7.0" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", +] + +[[package]] +name = "rats-corim" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/rats-corim#f0d5d5168d3d31487a56df32c676b0c6240bcc6b" +dependencies = [ + "ciborium", + "ciborium-io", + "clap", + "hex", + "serde", + "serde_with", + "strum", + "thiserror 2.0.20", ] [[package]] @@ -2648,14 +3619,34 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2665,9 +3656,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2676,15 +3667,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regress" -version = "0.10.5" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" dependencies = [ "hashbrown 0.16.1", "memchr", @@ -2692,77 +3683,37 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-rustls 0.24.2", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls 0.21.12", - "rustls-pemfile 1.0.4", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration 0.5.1", - "tokio", - "tokio-rustls 0.24.1", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", - "winreg", -] - -[[package]] -name = "reqwest" -version = "0.12.24" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2 0.4.12", - "http 1.4.0", - "http-body 1.0.1", + "h2", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", - "hyper 1.8.1", - "hyper-rustls 0.27.7", - "hyper-tls", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "mime", - "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", - "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2792,7 +3743,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2809,12 +3760,12 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -2832,7 +3783,7 @@ dependencies = [ [[package]] name = "rumors" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/rumors?rev=748325407feb4c61f558713bad431281f8326ac4#748325407feb4c61f558713bad431281f8326ac4" +source = "git+https://github.com/oxidecomputer/rumors?rev=66aea68b6578345275de3cf14151c4707345773f#66aea68b6578345275de3cf14151c4707345773f" dependencies = [ "async-stream", "before", @@ -2842,87 +3793,76 @@ dependencies = [ "futures", "futures-util", "hex", - "imbl", "itertools", - "pollster", - "rand 0.8.5", + "rand 0.8.7", "seq-macro", + "smallvec 1.15.2", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tokio", "tokio-stream", - "tokio-util", ] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "rustc-demangle" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] -name = "rustix" -version = "1.1.2" +name = "rustc-hash" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] -name = "rustls" -version = "0.21.12" +name = "rustc_version" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", + "semver", ] [[package]] -name = "rustls" -version = "0.22.4" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "log", - "ring", - "rustls-pki-types", - "rustls-webpki 0.102.8", - "subtle", - "zeroize", + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "rustls-pki-types", - "rustls-webpki 0.103.8", + "rustls-webpki", "subtle", "zeroize", ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustls-native-certs" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "base64 0.21.7", + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", ] [[package]] @@ -2936,40 +3876,48 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] [[package]] -name = "rustls-webpki" -version = "0.101.7" +name = "rustls-platform-verifier" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "ring", - "untrusted", + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] [[package]] -name = "rustls-webpki" -version = "0.102.8" +name = "rustls-platform-verifier-android" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2977,9 +3925,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rustyline" @@ -2987,7 +3935,7 @@ version = "17.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "cfg-if", "clipboard-win", "fd-lock", @@ -2997,32 +3945,43 @@ dependencies = [ "memchr", "nix", "radix_trie", + "termios", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", "utf8parse", "windows-sys 0.60.2", ] [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salty" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "b947325a585e90733e0e9ec097228f40b637cc346f9bd68f84d5c6297d0fcfef" +dependencies = [ + "subtle", + "zeroize", +] [[package]] -name = "safe_arch" -version = "0.7.4" +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ - "bytemuck", + "winapi-util", ] [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -3035,13 +3994,36 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "chrono", "dyn-clone", - "indexmap 1.9.3", "schemars_derive", "serde", "serde_json", "uuid", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -3051,7 +4033,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -3060,16 +4042,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "sec1" version = "0.7.3" @@ -3084,14 +4056,23 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags 2.13.1", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3099,9 +4080,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3109,9 +4090,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -3125,14 +4106,23 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", ] +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + [[package]] name = "serde-hex" version = "0.1.0" @@ -3146,22 +4136,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 3.0.3", ] [[package]] @@ -3172,14 +4162,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3201,23 +4191,32 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.3" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] [[package]] name = "serde_tokenstream" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -3232,13 +4231,46 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -3247,13 +4279,24 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3263,8 +4306,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest 0.10.7", + "keccak", ] [[package]] @@ -3273,12 +4326,19 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -3288,15 +4348,37 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "sled-hardware-types" @@ -3305,10 +4387,10 @@ source = "git+https://github.com/oxidecomputer/omicron?tag=rel%2Fv21%2Frc1#3e5ba dependencies = [ "daft", "omicron-workspace-hack", - "schemars", + "schemars 0.8.22", "serde", "slog", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3347,6 +4429,25 @@ dependencies = [ "time", ] +[[package]] +name = "slog-error-chain" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/slog-error-chain?branch=main#15f69041f45774602108e47fb25e705dc23acfb2" +dependencies = [ + "slog", + "slog-error-chain-derive", +] + +[[package]] +name = "slog-error-chain-derive" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/slog-error-chain?branch=main#15f69041f45774602108e47fb25e705dc23acfb2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "slog-json" version = "2.6.1" @@ -3384,35 +4485,31 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] -name = "socket2" -version = "0.5.10" +name = "smawk" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -3427,6 +4524,47 @@ dependencies = [ "der", ] +[[package]] +name = "sprockets-tls" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/sprockets.git?rev=b8782742d1bd91546887fbc1757a7d024a5469ef#b8782742d1bd91546887fbc1757a7d024a5469ef" +dependencies = [ + "anyhow", + "attest-data 0.5.0 (git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d)", + "camino", + "cfg-if", + "clap", + "dice-mfg-msgs", + "dice-verifier", + "ed25519-dalek", + "hubpack", + "pem-rfc7468", + "rustls", + "secrecy", + "serde", + "sha2", + "sha3", + "slog", + "slog-async", + "slog-error-chain", + "slog-term", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "toml 0.8.23", + "x509-cert", + "zeroize", +] + +[[package]] +name = "sprockets-tls-test-utils" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/sprockets.git?rev=b8782742d1bd91546887fbc1757a7d024a5469ef#b8782742d1bd91546887fbc1757a7d024a5469ef" +dependencies = [ + "camino", + "pki-playground 0.2.0 (git+https://github.com/oxidecomputer/pki-playground?rev=7600756029ce046a02c6234aa84ce230cc5eaa04)", +] + [[package]] name = "ssh-cipher" version = "0.2.0" @@ -3496,35 +4634,101 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "stacker" -version = "0.1.24" +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.61.2", + "strum_macros", ] [[package]] -name = "static_assertions" -version = "1.1.0" +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "suanpan" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/rumors?rev=66aea68b6578345275de3cf14151c4707345773f#66aea68b6578345275de3cf14151c4707345773f" +dependencies = [ + "dashu-int", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" [[package]] -name = "strsim" -version = "0.11.1" +name = "supports-unicode" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "f850c19edd184a205e883199a261ed44471c81e39bd95b1357f5febbef00e77a" +dependencies = [ + "is-terminal", +] [[package]] -name = "subtle" -version = "2.6.1" +name = "supports-unicode" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "sush-api" @@ -3534,8 +4738,8 @@ dependencies = [ "chrono", "dropshot", "http-range-header", - "hyper 1.8.1", - "schemars", + "hyper", + "schemars 0.8.22", "serde", "sled-hardware-types", "sush-common", @@ -3555,7 +4759,7 @@ dependencies = [ "clap", "ed25519-dalek", "futures", - "http 1.4.0", + "http 1.5.0", "http-range-header", "humantime", "indicatif", @@ -3566,15 +4770,19 @@ dependencies = [ "permission-slip-common", "progenitor", "progenitor-client", - "rand 0.8.5", - "reqwest 0.12.24", + "rand 0.8.7", + "reqwest", "rustix", + "rustls", "rustyline", "serde", "serde_json", - "shlex", + "sha3", + "shlex 1.3.0", "signature", "sled-hardware-types", + "slog", + "sprockets-tls", "ssh-key", "sush-api", "sush-common", @@ -3598,15 +4806,15 @@ dependencies = [ "chrono", "crypto-bigint", "ed25519-dalek", - "http 1.4.0", + "http 1.5.0", "http-range-header", "p256", "pem-rfc7468", - "rand 0.8.5", + "rand 0.8.7", "rand_core 0.6.4", "rlimit", "rustix", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "sha2", @@ -3625,41 +4833,49 @@ dependencies = [ name = "sush-server" version = "0.1.0" dependencies = [ + "attest-mock", "blake3", "borsh", "bytes", "bytesize", + "camino", "chrono", "clap", "dropshot", "ed25519-dalek", "function_name", "futures", - "http 1.4.0", + "http 1.5.0", "http-body-util", "http-range-header", - "hyper 1.8.1", + "hyper", + "hyper-util", "libc", "lru", "memmap2", "p256", + "percent-encoding", "pwd", "rand_core 0.6.4", "rumors", "rustix", - "schemars", + "rustls", + "schemars 0.8.22", "serde", "serde_json", "sha2", "sled-hardware-types", "slog", "slog-term", + "sprockets-tls", + "sprockets-tls-test-utils", "ssh-key", "sush-api", "sush-common", "tempfile", "thiserror 1.0.69", "tokio", + "tokio-rustls", "tokio-stream", "tokio-tungstenite", "tokio-util", @@ -3672,17 +4888,22 @@ name = "sush-tests" version = "0.1.0" dependencies = [ "bytes", + "camino", "chrono", "dropshot", + "ed25519-dalek", "function_name", "futures", "http-range-header", "pwd", "rand_core 0.6.4", "rumors", + "sha3", "sled-hardware-types", "slog", "slog-term", + "sprockets-tls", + "sprockets-tls-test-utils", "sush-api", "sush-client", "sush-common", @@ -3707,9 +4928,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.115" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -3717,10 +4938,15 @@ dependencies = [ ] [[package]] -name = "sync_wrapper" -version = "0.1.2" +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] [[package]] name = "sync_wrapper" @@ -3739,39 +4965,18 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", -] - -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys 0.5.0", + "syn 2.0.119", ] [[package]] name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.10.0", - "core-foundation", - "system-configuration-sys 0.6.0", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "core-foundation-sys", - "libc", + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", ] [[package]] @@ -3798,12 +5003,12 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3811,21 +5016,61 @@ dependencies = [ [[package]] name = "term" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2111ef44dae28680ae9752bb89409e7310ca33a8c621ebe7b106cf5c928b3ac0" +checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "terminal_size" -version = "0.4.3" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "textwrap" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.1.14", +] + +[[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]] @@ -3839,11 +5084,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -3854,57 +5099,56 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.44" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3912,9 +5156,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3922,9 +5166,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -3953,14 +5197,14 @@ checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3968,7 +5212,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -3985,44 +5229,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" -dependencies = [ - "rustls 0.22.4", - "rustls-pki-types", - "tokio", + "syn 3.0.3", ] [[package]] @@ -4031,15 +5244,15 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.35", + "rustls", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -4060,79 +5273,120 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", - "futures-io", "futures-sink", + "libc", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "0.9.8" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", ] [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.9" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ - "indexmap 2.13.0", - "toml_datetime", + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -4140,20 +5394,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "iri-string", + "http 1.5.0", + "http-body 1.1.0", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -4170,19 +5424,31 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] @@ -4201,26 +5467,26 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.5.0", "httparse", "log", - "rand 0.9.2", - "sha1", - "thiserror 2.0.18", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.20", "utf-8", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typify" -version = "0.4.3" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" +checksum = "bc0b89f47309feaeb23c4509c15c9a04234f7deccef6f96c3bfe95319819a304" dependencies = [ "typify-impl", "typify-macro", @@ -4228,52 +5494,64 @@ dependencies = [ [[package]] name = "typify-impl" -version = "0.4.3" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" +checksum = "fa7b026f540b148b81043c720889dbb942b08659aa8a43f624ac4f04dbfc1861" dependencies = [ - "heck", + "heck 0.5.0", "log", "proc-macro2", "quote", "regress", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", - "syn 2.0.115", - "thiserror 2.0.18", + "syn 2.0.119", + "thiserror 2.0.20", "unicode-ident", ] [[package]] name = "typify-macro" -version = "0.4.3" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" +checksum = "39ed96c57f06ae0839416b986921a98f18b220da63bbb243a8570a00c8492183" dependencies = [ "proc-macro2", "quote", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", "serde_tokenstream", - "syn 2.0.115", + "syn 2.0.119", "typify-impl", ] [[package]] name = "unicode-ident" -version = "1.0.23" +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 = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" @@ -4301,14 +5579,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -4331,30 +5610,30 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "borsh", "borsh-derive", - "getrandom 0.3.4", + "getrandom 0.4.3", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "waitgroup" version = "0.1.2" @@ -4364,6 +5643,16 @@ dependencies = [ "atomic-waker", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -4381,18 +5670,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -4403,23 +5692,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4427,31 +5712,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -4462,9 +5747,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -4481,19 +5766,12 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - -[[package]] -name = "wide" -version = "0.7.33" +name = "webpki-root-certs" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ - "bytemuck", - "safe_arch", + "rustls-pki-types", ] [[package]] @@ -4512,6 +5790,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -4526,7 +5813,7 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", + "windows-link", "windows-result", "windows-strings", ] @@ -4539,7 +5826,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -4550,15 +5837,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -4571,7 +5852,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows-result", "windows-strings", ] @@ -4582,7 +5863,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4591,23 +5872,23 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "windows-sys" -version = "0.48.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] @@ -4627,22 +5908,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", + "windows-link", ] [[package]] @@ -4667,7 +5933,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -4678,12 +5944,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4696,12 +5956,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4714,12 +5968,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4744,12 +5992,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4762,12 +6004,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4780,12 +6016,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4798,12 +6028,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -4818,34 +6042,33 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] [[package]] -name = "winreg" -version = "0.50.0" +name = "winnow" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ - "cfg-if", - "windows-sys 0.48.0", + "memchr", ] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "wyz" @@ -4862,7 +6085,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", "spki", "tls_codec", @@ -4891,9 +6114,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4902,82 +6125,82 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.28" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43fa6694ed34d6e57407afbccdeecfa268c470a7d2a5b0cf49ce9fcc345afb90" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.28" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -4986,9 +6209,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -4997,17 +6220,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index db34c69..3cba426 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,9 @@ resolver = "2" [workspace.dependencies] async-recursion = "1" +attest-mock = { git = "https://github.com/oxidecomputer/dice-util", rev = "10952e8d9599b735b85d480af3560a11700e5b64" } base64 = "0.22" +camino = "1" blake3 = { version = "1", features = ["mmap", "rayon"] } borsh = { version = "1", features = ["derive"] } bytes = "1" @@ -12,46 +14,53 @@ bytesize = "2" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive", "env", "wrap_help"] } crypto-bigint = "0.5" -dropshot = "0.16" -ed25519-dalek = { version = "2", features = ["rand_core"] } +dropshot = "0.17" +ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } function_name = "0.3" futures = "0.3" http = "1" http-body-util = "0.1" http-range-header = "0.4" humantime = "2" -hyper = "1" +hyper = { version = "1", features = ["client", "http1", "server"] } +hyper-util = { version = "0.1", features = ["tokio"] } indicatif = "0.18" libc = "0.2" lru = "0.18" memmap2 = "0.9" -p256 = { version = "0.13", features = ["ecdsa"] } +p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } pem-rfc7468 = { version = "0.7", features = ["std"] } +percent-encoding = "2" permission-slip-client = { git = "https://github.com/oxidecomputer/permission-slip" } permission-slip-common = { git = "https://github.com/oxidecomputer/permission-slip" } -progenitor = "0.11" -progenitor-client = "0.11" +progenitor = "0.14" +progenitor-client = "0.14" pwd = "1" rand = "0.8" rand_core = "0.6" -reqwest = "0.12" +reqwest = { version = "0.13", features = ["json", "rustls"] } rlimit = "0.10" -rumors = { git = "https://github.com/oxidecomputer/rumors", rev = "748325407feb4c61f558713bad431281f8326ac4" } +rumors = { git = "https://github.com/oxidecomputer/rumors", rev = "66aea68b6578345275de3cf14151c4707345773f" } rustix = { version = "1", features = ["fs", "process", "pty", "termios"] } -rustyline = "17" -schemars = { version = "0.8", features = ["chrono", "preserve_order"] } +rustls = "0.23" +rustyline = { version = "17", features = ["termios"] } +schemars = { version = "0.8", features = ["chrono"] } serde = "1" serde_json = "1" sha2 = "0.10" +sha3 = "0.10" shlex = "1" signature = "2" sled-hardware-types = { git = "https://github.com/oxidecomputer/omicron", tag = "rel/v21/rc1" } slog = "2" slog-term = "2" +sprockets-tls = { git = "https://github.com/oxidecomputer/sprockets.git", rev = "b8782742d1bd91546887fbc1757a7d024a5469ef", default-features = false } +sprockets-tls-test-utils = { git = "https://github.com/oxidecomputer/sprockets.git", rev = "b8782742d1bd91546887fbc1757a7d024a5469ef" } ssh-key = { version = "0.6", features = ["ed25519", "p256", "serde"] } tempfile = "3" thiserror = "1" tokio = { version = "1", features = ["macros", "process", "rt", "sync", "time"] } +tokio-rustls = "0.26" tokio-stream = "0.1" tokio-fd = "0.3" tokio-tungstenite = "0.28" @@ -59,3 +68,22 @@ tokio-util = "0.7" uuid = { version = "1", features = ["borsh"] } x509-cert = { version = "0.2", features = ["pem", "std"] } xdg = "3" + +# Every sprockets handshake verifies P-384 attestation cert chains, and every +# gossip link stream is a handshake. The RustCrypto stack is ~100ms per verify +# unoptimized, which makes the sprockets-backed tests crawl, so optimize the +# crates that do the math; everything else keeps fast debug builds. +[profile.dev.package.crypto-bigint] +opt-level = 3 +[profile.dev.package.ecdsa] +opt-level = 3 +[profile.dev.package.ed25519-dalek] +opt-level = 3 +[profile.dev.package.elliptic-curve] +opt-level = 3 +[profile.dev.package.p384] +opt-level = 3 +[profile.dev.package.primeorder] +opt-level = 3 +[profile.dev.package.sha3] +opt-level = 3 diff --git a/README.md b/README.md index f049c2b..4a69aba 100644 --- a/README.md +++ b/README.md @@ -48,23 +48,18 @@ test try running the command `echo $SUSH_JOB_ID`: ``` sush# job start --wait "echo $SUSH_JOB_ID" ✅ Signed request for job `install-there-mutual-warfare-sound-live-order-man` -✅ Job ID: install-there-mutual-warfare-sound-live-order-man - Target: a part:0001 - Job status: Stopped - Started at: 2026-08-05 16:27:58.824606210 UTC - Stopped at: 2026-08-05 16:27:58.892796324 UTC (68ms 190us 114ns) - Exit status: 0 - Stdout len: 50 B - Stderr len: 0 B - Stdout hash: ae5a3df9c5c629590af52baceb12021525c0be16994738977b2ed049f4f374bb - Stderr hash: af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262 +✅ Job ID: install-there-mutual-warfare-sound-live-order-man + a part:0001 Stopped, exit 0 (68ms 190us 114ns), 50 B out, 0 B err ✅ Job stdout: install-there-mutual-warfare-sound-live-order-man ``` The `--wait` (`-w`) flag tells `sush` to wait for the job to stop before -returning; the default behavior is to start the job and immediately return. -You can check the status of a (running) job with `job status`. +returning, showing a live status line per sled while it runs; the default +behavior is to start the job and immediately return. You can check the +status of a (running) job with `job status`, watch it the same way with +`job status --wait`, or see the complete job status with +`job status --full`. To run an interactive job with a pseudoterminal, you can use `job start --interactive` (`-i`): diff --git a/api/src/lib.rs b/api/src/lib.rs index 8acb507..384ba6a 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -74,6 +74,7 @@ pub trait SushApi { async fn iam( ctx: RequestContext, headers: Header, + query: QueryParams, body: TypedBody>, ) -> Result, HttpError>; @@ -88,14 +89,14 @@ pub trait SushApi { /// Revoke an SSH identity. /// - /// Expires the key's cached identities and refuses its future - /// logins. Identities are not shared across the rack, so this - /// applies only to the server handling the request. + /// Expires the key's identities and refuses its future logins, + /// across the rack. #[endpoint { method = POST, path = "/iam/{key_id}/revoke" }] async fn iam_revoke( ctx: RequestContext, headers: Header, params: PathParams, + query: QueryParams, ) -> Result; // Session management. @@ -194,6 +195,7 @@ pub trait SushApi { ctx: RequestContext, headers: Header, params: PathParams, + query: QueryParams, ) -> Result, HttpError>; /// Attach to an interactive job. @@ -205,6 +207,7 @@ pub trait SushApi { ctx: RequestContext, headers: Header, params: PathParams, + query: QueryParams, upgrade: WebsocketUpgrade, ) -> WebsocketEndpointResult; @@ -220,13 +223,24 @@ pub trait SushApi { ) -> Result>, HttpError>; /// Get the baseboard ID of the sled handling this request. + /// + /// Unauthenticated: discovery must work before anyone can log in, + /// and this reveals only the sled's baseboard. Routed through a + /// proxy, this resolves a target expression to a baseboard. #[endpoint { method = GET, path = "/target" }] async fn target( ctx: RequestContext, - headers: Header, + query: QueryParams, ) -> Result, HttpError>; } +/// A routing hint for a fronting proxy. Sleds ignore it. +#[derive(Deserialize, JsonSchema)] +pub struct RoutingParam { + /// Where a proxy should route this request. + pub via: Option, +} + #[derive(Deserialize, JsonSchema)] pub struct KeyIdParam { pub key_id: KeyId, diff --git a/client/Cargo.toml b/client/Cargo.toml index c95a0c8..b37cd25 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -40,6 +40,10 @@ progenitor-client.workspace = true rand.workspace = true reqwest.workspace = true rustix.workspace = true +rustls.workspace = true +sha3.workspace = true +slog.workspace = true +sprockets-tls.workspace = true rustyline.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/client/certs/production.pem b/client/certs/production.pem new file mode 100644 index 0000000..1604125 --- /dev/null +++ b/client/certs/production.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICcTCCAfigAwIBAgIBADAKBggqhkjOPQQDAzBRMQswCQYDVQQGEwJVUzEfMB0G +A1UECgwWT3hpZGUgQ29tcHV0ZXIgQ29tcGFueTEhMB8GA1UEAwwYUGxhdGZvcm0g +SWRlbnRpdHkgUm9vdCBBMCAXDTIzMDUyMjIxMzg1MFoYDzk5OTkxMjMxMjM1OTU5 +WjBRMQswCQYDVQQGEwJVUzEfMB0GA1UECgwWT3hpZGUgQ29tcHV0ZXIgQ29tcGFu +eTEhMB8GA1UEAwwYUGxhdGZvcm0gSWRlbnRpdHkgUm9vdCBBMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAEW+aN+vsjVwvS7lw+HdxmhMtfftu/5eA+NtwmYP5DfgJHsjqy +dktnfKWG97PG8fmo2Mtzl3CCRNJHCLhbSDT4uoNFa5CJjVyVTful/ZDI0RcLEgMH +MMHXUImNhSK/FFeIo4GhMIGeMB0GA1UdDgQWBBQMlvnuPfBysNWrdaPGCNAtbY4b +xDAfBgNVHSMEGDAWgBQMlvnuPfBysNWrdaPGCNAtbY4bxDAPBgNVHRMBAf8EBTAD +AQH/MA4GA1UdDwEB/wQEAwIBBjA7BgNVHSABAf8EMTAvMAwGCisGAQQBg8FPAQMw +CQYHZ4EFBQRkBjAJBgdngQUFBGQIMAkGB2eBBQUEZAwwCgYIKoZIzj0EAwMDZwAw +ZAIwN3aogW48yUtP2xzAh0DUKRw0AXlaZEUNXPcLLTh/NjqNeTiPZNy0Kpe1pqi3 +kJ6WAjAVL/Ap5zI0j2U8XAbLzpc7XK1RDMJNPdoAHI1cIsrcnUnpm/8eLclt5Xf4 +gxUnrfA= +-----END CERTIFICATE----- diff --git a/client/certs/staging.pem b/client/certs/staging.pem new file mode 100644 index 0000000..09bf060 --- /dev/null +++ b/client/certs/staging.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICgzCCAgigAwIBAgIBADAKBggqhkjOPQQDAzBZMQswCQYDVQQGEwJVUzEfMB0G +A1UECgwWT3hpZGUgQ29tcHV0ZXIgQ29tcGFueTEpMCcGA1UEAwwgUGxhdGZvcm0g +SWRlbnRpdHkgU3RhZ2luZyBSb290IEEwIBcNMjMwNTEyMTgwMTEwWhgPOTk5OTEy +MzEyMzU5NTlaMFkxCzAJBgNVBAYTAlVTMR8wHQYDVQQKDBZPeGlkZSBDb21wdXRl +ciBDb21wYW55MSkwJwYDVQQDDCBQbGF0Zm9ybSBJZGVudGl0eSBTdGFnaW5nIFJv +b3QgQTB2MBAGByqGSM49AgEGBSuBBAAiA2IABK1d87ebQev5oHgo6RP0o+MktmKH +7hshgq/Je8mL/PkiZExwk6mUXeCTezFYh2Q8JvKPYB3w7tMeQJfP//AyrjT++sbC +R0dYoDVCMAS8bfWPy2NubVq7zARTEZghxjNbdaOBoTCBnjAdBgNVHQ4EFgQUrPuv +seaM7OJn2u5AtAv7E1ZRSzswHwYDVR0jBBgwFoAUrPuvseaM7OJn2u5AtAv7E1ZR +SzswDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwOwYDVR0gAQH/BDEw +LzAMBgorBgEEAYPBTwEDMAkGB2eBBQUEZAYwCQYHZ4EFBQRkCDAJBgdngQUFBGQM +MAoGCCqGSM49BAMDA2kAMGYCMQCZqTzBAml+TcG02dy8xnIMmst3S691Ivm4pARH +Cuyi/Mm/6OVLV1aSwVBgP+jGwuMCMQDzjsOQuPB6cvYhj2Xez2u10Dv2CYiby3WH ++wopH5rw4znkn6YJYyaUlhjjAdsXcLw= +-----END CERTIFICATE----- diff --git a/client/src/cli.rs b/client/src/cli.rs index 06a95d8..97fe1ec 100644 --- a/client/src/cli.rs +++ b/client/src/cli.rs @@ -4,6 +4,8 @@ //! Possibly-interactive command-line interface. +use std::collections::BTreeMap; +use std::fmt; use std::io::{self, BufRead as _, Read as _, Write as _, stderr, stdin, stdout}; use std::path::Path; use std::sync::{Arc, Mutex}; @@ -12,9 +14,10 @@ use std::time::{Duration, SystemTime}; use bytesize::ByteSize; use chrono::TimeDelta; use humantime::format_duration; -use indicatif::{ProgressBar, ProgressStyle}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use rustix::io::ioctl_fionread; use serde_json::{json, to_string as to_json_string, to_string_pretty as to_json_string_pretty}; +use sled_hardware_types::BaseboardId; use x509_cert::Certificate; use x509_cert::der::Encode as _; @@ -27,13 +30,14 @@ use sush_common::keys::{KeyId, Signature, SshPublicKey}; use crate::AuthzSigner; use crate::commands::{CommandError, GlobalArgs}; -use crate::context::{CommandContext, OutputFormat}; +use crate::context::{CommandContext, OutputFormat, StatusDisplayStyle}; #[derive(Clone, Debug, Default)] pub struct Cli { globals: Arc>, output: Arc>, progress: Arc>>, + watch: Arc>>, session: Arc>>, credentials: AuthzSigner, } @@ -42,6 +46,57 @@ fn byte_size(len: u64) -> bytesize::Display { ByteSize::b(len).display().si() } +fn format_elapsed_duration(duration: TimeDelta) -> String { + if let Ok(duration) = duration.to_std() { + format_duration(duration).to_string() + } else { + String::from("negative duration, times may be unreliable") + } +} + +/// One sled's worth of job status, without the sled's name. +fn short_status_row(status: &JobStatus) -> String { + match status { + JobStatus::Cancelled { + time_cancelled, + actor, + .. + } => format!("Cancelled at {time_cancelled} by {actor}"), + JobStatus::Queued { + time_queued, actor, .. + } => format!("Queued at {time_queued} by {actor}"), + JobStatus::Started { time_started, .. } => format!("Started at {time_started}"), + JobStatus::Stopped { result, output, .. } => { + let duration = format_elapsed_duration(status.time_elapsed()); + let stdout_len = byte_size(output.stdout_len); + let stderr_len = byte_size(output.stderr_len); + let result = match result { + Ok(exit_status) => format!("exit {exit_status}"), + Err(err) => err.to_string(), + }; + format!("Stopped, {result} ({duration}), {stdout_len} out, {stderr_len} err") + } + JobStatus::Error { + time_error, error, .. + } => format!("Error at {time_error}: {error}"), + } +} + +/// Live per-sled status lines for a watched job. +struct Watch { + multi: MultiProgress, + bars: BTreeMap, + width: usize, +} + +impl fmt::Debug for Watch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Watch") + .field("bars", &self.bars.keys()) + .finish_non_exhaustive() + } +} + impl CommandContext for Cli { // Context management @@ -96,7 +151,7 @@ impl CommandContext for Cli { fn session_stopped(&mut self, session_id: &SessionId) -> Result<(), CommandError> { let mut session_guard = self.session.lock().unwrap(); if let Some(session) = session_guard.as_ref() - && session.session_id() == session_id + && session.session_id() == *session_id { let _ = session_guard.take(); } @@ -209,7 +264,13 @@ impl CommandContext for Cli { fn job_stopped(&mut self, job_id: &JobId) { match self.get_output_format() { OutputFormat::Json => println!("{}", json!(job_id)), - OutputFormat::Text => println!("\r✅ Stopped job `{job_id}`"), + OutputFormat::Text => { + if let Some(watch) = self.watch.lock().unwrap().as_ref() { + let _ = watch.multi.println(format!("✅ Stopped job `{job_id}`")); + } else { + println!("\r✅ Stopped job `{job_id}`"); + } + } } } @@ -218,6 +279,13 @@ impl CommandContext for Cli { error } + fn job_output_target(&mut self, target: &BaseboardId) { + match self.get_output_format() { + OutputFormat::Json => println!("{}", json!({ "target": target.to_string() })), + OutputFormat::Text => println!("⟹ {target} ⟸"), + } + } + fn job_output( &mut self, _job_id: &JobId, @@ -301,34 +369,61 @@ impl CommandContext for Cli { } } - fn job_polling_started(&mut self, job_id: &JobId, elapsed: Duration) { - let mut progress = self.progress.lock().unwrap(); - if matches!(self.get_output_format(), OutputFormat::Text) && progress.is_none() { - let bar = ProgressBar::new_spinner(); - bar.set_elapsed(elapsed); - bar.set_prefix(format!("Waiting for job `{job_id}`")); - bar.set_style( - ProgressStyle::with_template( - "{spinner} \ - {prefix} \ - [{elapsed_precise}] \ - {msg}", - ) - .unwrap(), - ); - *progress = Some(bar); + fn job_watch_started(&mut self, _job_id: &JobId) { + if matches!(self.get_output_format(), OutputFormat::Text) { + *self.watch.lock().unwrap() = Some(Watch { + multi: MultiProgress::new(), + bars: BTreeMap::new(), + width: 0, + }); + } + } + + fn job_watch_update(&mut self, status: &JobStatusMap) { + let mut guard = self.watch.lock().unwrap(); + let Some(watch) = guard.as_mut() else { return }; + + // Widen every name column if a longer baseboard ID appears. + let width = status + .keys() + .map(|b| b.to_string().len()) + .max() + .unwrap_or(0); + if width > watch.width { + watch.width = width; + for (baseboard_id, bar) in &watch.bars { + bar.set_prefix(format!("{: String { - if let Ok(duration) = duration.to_std() { - format_duration(duration).to_string() - } else { - String::from("negative duration, times may be unreliable") - } - } - - // TODO: parallel status display + fn job_status(&mut self, job_id: &JobId, status: &JobStatusMap, style: StatusDisplayStyle) { match self.get_output_format() { OutputFormat::Json => println!("{}", json!(job_status_to_json_map(status.clone()))), + OutputFormat::Text if matches!(style, StatusDisplayStyle::Short) => { + let icon = if status.values().any(|s| { + matches!( + s, + JobStatus::Error { .. } + | JobStatus::Cancelled { .. } + | JobStatus::Stopped { result: Err(_), .. } + ) + }) { + "❌" + } else { + "✅" + }; + let width = status + .keys() + .map(|b| b.to_string().len()) + .max() + .unwrap_or(0); + println!("{icon} Job ID:\t{job_id}"); + for (baseboard_id, status) in status { + let id = baseboard_id.to_string(); + println!(" {id: { for (baseboard_id, status) in status { match status { diff --git a/client/src/commands.rs b/client/src/commands.rs index 9e66819..ad29c6b 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -11,6 +11,7 @@ use std::io::{Read as _, Seek as _, SeekFrom, Write as _, stdin}; use std::num::{NonZeroU8, NonZeroU64}; use std::path::{Path, PathBuf}; use std::pin::Pin; +use std::str::FromStr; use std::time::Duration; use async_recursion::async_recursion; @@ -45,18 +46,20 @@ use sush_common::jobs::JobOutputStream::{self, Stderr, Stdout}; #[cfg(feature = "permslip")] use sush_common::jobs::JobStartRequest; use sush_common::jobs::{ - Access, JobId, JobLimits, JobOutputHash, JobOutputState, JobStatus, Session, SessionId, - SignedJob, job_status_try_from_json_map, + Access, JobId, JobLimits, JobOutputHash, JobOutputState, JobStatus, JobStatusMap, Session, + SessionId, SignedJob, job_status_try_from_json_map, }; use sush_common::keys::{KeyError, KeyId, Signer as _}; +use sush_common::targets::{SledId, Target}; use crate::ByteStream; -use crate::context::{Authz, CommandContext, OutputFormat}; +use crate::context::{Authz, CommandContext, OutputFormat, StatusDisplayStyle}; use crate::identity::{IdentityError, SshAgentConnection}; use crate::interactive::interactive_job; #[cfg(feature = "permslip")] use crate::permslip::{PermslipError, PermslipSigner}; use crate::repl::Repl; +use crate::tls; use crate::types::Error as ApiError; use crate::{Client, Error as ClientError}; @@ -73,6 +76,7 @@ pub const SUSH_MAX_FSIZE: &str = "SUSH_MAX_FSIZE"; #[cfg(feature = "permslip")] pub const SUSH_PERMSLIP_KEY: &str = "SUSH_PERMSLIP_KEY"; pub const SUSH_OUTPUT_FORMAT: &str = "SUSH_OUTPUT_FORMAT"; +pub const SUSH_PROXY_ROOT: &str = "SUSH_PROXY_ROOT"; pub const SUSH_URL: &str = "SUSH_URL"; /// Default chunk size for parallel downloads of large output. @@ -85,7 +89,6 @@ const PARALLEL_CHUNKS: NonZeroU8 = NonZeroU8::new(8).unwrap(); const MAX_PARALLEL_CHUNKS: NonZeroU8 = NonZeroU8::new(64).unwrap(); // Job polling and spinner update intervals. -const JOB_START_UPDATE_INTERVAL: Duration = Duration::from_millis(250); const JOB_STOP_RETRY_INTERVAL: Duration = Duration::from_millis(100); #[cfg(feature = "permslip")] const SIGNING_UPDATE_INTERVAL: Duration = Duration::from_millis(100); @@ -151,6 +154,12 @@ pub struct GlobalArgs { #[clap(global = true)] pub url: Option, + /// PEM roots that a proxy's TLS certificate must chain to, + /// replacing the baked-in platform identity roots. + #[arg(long = "proxy-root", env = SUSH_PROXY_ROOT, value_name = "PEM")] + #[clap(global = true)] + pub proxy_roots: Vec, + /// Offline mode, i.e., job signing only #[arg(long, default_value_t = false, conflicts_with = "url")] #[clap(global = true)] @@ -186,6 +195,99 @@ impl ClientArgs { } } +/// A rack-wide target can't combine with `--binary` or `--file`. +#[tokio::test] +async fn output_needs_target() { + use crate::cli::Cli; + + let client = + Client::new_with_client("http://[::1]:1", reqwest::Client::new(), Default::default()); + let output = JobOutput::try_parse_from([ + "job-stdout", + "--binary", + "sea-say-sting-palm-tunnel-festival-pull-bid", + ]) + .unwrap(); + let err = job_output( + &mut Cli::default(), + &client, + &"*".parse::().unwrap(), + Stdout, + output, + ) + .await + .unwrap_err(); + assert!(matches!(err, CommandError::OutputNeedsTarget)); +} + +/// The watch settles only after the sled set is stable and every sled +/// is terminal for consecutive polls. +#[test] +fn watch_settling() { + use chrono::Utc; + + fn sled(serial: &str) -> BaseboardId { + BaseboardId { + part_number: "913-0000019".to_owned(), + serial_number: serial.to_owned(), + } + } + let job_id: JobId = "sea-say-sting-palm-tunnel-festival-pull-bid" + .parse() + .unwrap(); + let terminal = JobStatus::Cancelled { + job_id, + time_cancelled: Utc::now(), + actor: KeyId::random(), + }; + let running = JobStatus::Started { + job_id, + time_started: Utc::now(), + }; + + let mut quiet = 0; + let mut status = JobStatusMap::new(); + assert!( + !watch_done(1, 0, &status, &mut quiet), + "empty never settles" + ); + + status.insert(sled("A"), terminal.clone()); + assert!(!watch_done(2, 0, &status, &mut quiet), "new sled resets"); + assert!(!watch_done(3, 1, &status, &mut quiet), "first quiet poll"); + assert!( + !watch_done(4, 1, &status, &mut quiet), + "quiet but too young for gossip" + ); + assert!(watch_done(5, 1, &status, &mut quiet), "old enough, quiet"); + + let mut quiet = 0; + status.insert(sled("B"), running); + assert!(!watch_done(6, 2, &status, &mut quiet), "running sled holds"); + assert_eq!(quiet, 0); + + status.insert(sled("B"), terminal); + assert!(!watch_done(7, 2, &status, &mut quiet)); + assert!( + watch_done(8, 2, &status, &mut quiet), + "settles once terminal" + ); +} + +/// Anything the target grammar accepts stays a target; bare serials +/// fall through; everything else still fails. +#[test] +fn target_arg() { + assert!(matches!("*".parse(), Ok(TargetArg::Target(_)))); + assert!(matches!("14".parse(), Ok(TargetArg::Target(_)))); + assert!(matches!( + "913-0000019:BRM42220030".parse(), + Ok(TargetArg::Target(_)) + )); + assert!(matches!("brm42220030".parse(), Ok(TargetArg::Serial(s)) if s == "brm42220030")); + assert!("not,a:target!".parse::().is_err()); +} + /// [`ClientArgs`] must satisfy Clap's internal consistency asserts /// (unique shorts per subcommand, valid references). #[test] @@ -320,7 +422,6 @@ pub enum IdentityCommand { Login, /// Revoke an SSH identity and refuse its future logins. - /// Applies only to the server handling the request. Revoke { key_id: KeyId }, } @@ -389,14 +490,22 @@ pub enum JobCommand { /// The job whose status should be fetched. #[clap(env = SUSH_JOB_ID)] job_id: JobId, + + /// Show full per-sled status instead of one line per sled. + #[arg(short, long)] + full: bool, + + /// Watch the status until the job settles on every sled. + #[arg(short, long)] + wait: bool, }, /// Get the standard output of a job. #[clap(alias = "output")] Stdout { - /// The baseboard ID of the sled from which output should be fetched. + /// The sled from which output should be fetched. #[arg(short = 'T', long, default_value = "*")] - target: String, + target: TargetArg, #[clap(flatten)] output: JobOutput, @@ -405,9 +514,9 @@ pub enum JobCommand { /// Get the standard error of a job. #[clap(alias = "error")] Stderr { - /// The baseboard ID of the sled from which output should be fetched. + /// The sled from which output should be fetched. #[arg(short = 'T', long, default_value = "*")] - target: String, + target: TargetArg, #[clap(flatten)] output: JobOutput, @@ -419,9 +528,9 @@ pub enum JobCommand { #[clap(env = SUSH_JOB_ID)] job_id: JobId, - /// The baseboard ID of the sled to attach to. + /// The sled to attach to. #[arg(short = 'T', long, default_value = "*")] - target: String, + target: TargetArg, }, /// Show status of previously started jobs. @@ -457,6 +566,11 @@ pub struct JobStartArgs { #[arg(short, long)] interactive: bool, + /// Where the job runs: every sled (`*`), or a comma-separated list + /// of cubby numbers and baseboard IDs. + #[arg(short = 'T', long, default_value = "*")] + target: Target, + /// Use `permslip` to sign job requests with this key name. #[cfg(feature = "permslip")] #[arg(short, long, env = SUSH_PERMSLIP_KEY, value_name = "KEY_NAME")] @@ -494,10 +608,26 @@ impl ClientCommand { ctx.set_output_format(output); } - let client = args - .url - .as_ref() - .map(|url| Client::new(url, ctx.authz_signer())); + let client = match args.url.as_ref() { + Some(url) => { + let roots = if args.proxy_roots.is_empty() { + tls::platform_roots()? + } else { + let mut roots = Vec::new(); + for path in &args.proxy_roots { + let pem = read(path).map_err(|err| CommandError::io(path, err))?; + roots.push(Certificate::from_pem(&pem)?); + } + roots + }; + Some(Client::new_with_client( + url, + tls::client(roots)?, + ctx.authz_signer(), + )) + } + None => None, + }; match (self, client) { (ClientCommand::Cert { command }, Some(client)) => cert(ctx, &client, command).await, @@ -544,6 +674,7 @@ impl ClientCommand { async fn authenticate( ctx: &mut impl CommandContext, client: &Client, + via: Option<&BaseboardId>, response: ResponseValue, ) -> Result<(Identity, Authz), CommandError> { let mut ssh_agent = if let Some(ssh_auth_sock) = &ctx.get_globals().ssh_auth_sock { @@ -570,13 +701,14 @@ async fn authenticate( }; let verified = signed.verify_with_ssh_public_key(&public_key)?; let credentials = Credentials::new(verified); - let identity = client + let mut request = client .iam() .authorization(credentials.to_string()) - .body(public_key.to_string()) - .send() - .await? - .into_inner(); + .body(public_key.to_string()); + if let Some(via) = via { + request = request.via(via.to_string()); + } + let identity = request.send().await?.into_inner(); Ok((identity, Authz::new(credentials, key))) } @@ -585,6 +717,22 @@ async fn authenticate( async fn with_login( ctx: &mut impl CommandContext, client: &Client, + make_request: Req, +) -> Result +where + Req: AsyncFnMut() -> Result>, + CommandError: From>, +{ + with_login_via(ctx, client, None, make_request).await +} + +/// Like [`with_login`], for requests a proxy routes to a particular +/// sled. Identities live on one sled, so the login must go where the +/// request it retries went. +async fn with_login_via( + ctx: &mut impl CommandContext, + client: &Client, + via: Option<&BaseboardId>, mut make_request: Req, ) -> Result where @@ -593,7 +741,7 @@ where { match make_request().await { Err(ClientError::ErrorResponse(err)) if err.status() == StatusCode::UNAUTHORIZED => { - let (_identity, authz) = authenticate(ctx, client, err).await?; + let (_identity, authz) = authenticate(ctx, client, via, err).await?; ctx.set_credentials(Some(authz)); Ok(make_request().await?) } @@ -648,7 +796,7 @@ async fn iam( IdentityCommand::Revoke { key_id } => { let key_id = ctx.really_revoke("SSH identity", key_id)?; with_login(ctx, client, async || { - client.iam_revoke().key_id(&key_id).send().await + client.iam_revoke().key_id(&key_id).wait(true).send().await }) .await?; ctx.revoked("SSH identity", key_id) @@ -712,7 +860,7 @@ async fn session( .await? .into_inner(); if let Some(session_id) = session_id - && *session.session_id() != session_id + && session.session_id() != session_id { return Err(CommandError::MissingSession); } @@ -734,7 +882,7 @@ async fn session( let session = if let Some(session_id) = session_id { Session::new(session_id) } else { - Session::new(SessionId::new()) + Session::new(SessionId::random()) }; with_login(ctx, client, async || { client @@ -762,7 +910,7 @@ async fn session( with_login(ctx, client, async || { client .session_allow_attach() - .session_id(session_id.clone()) + .session_id(session_id) .key_id(key_id.clone()) .access(access) .send() @@ -780,7 +928,7 @@ async fn session( with_login(ctx, client, async || { client .session_deny_attach() - .session_id(session_id.clone()) + .session_id(session_id) .key_id(key_id.clone()) .send() .await @@ -796,11 +944,7 @@ async fn session( return Err(CommandError::MissingSession); }; with_login(ctx, client, async || { - client - .session_stop() - .session_id(session_id.clone()) - .send() - .await + client.session_stop().session_id(*session_id).send().await }) .await?; ctx.session_stopped(session_id)?; @@ -855,6 +999,7 @@ async fn job( ref job_id, ref permslip_url, ref interactive, + ref target, .. }, }, @@ -872,7 +1017,7 @@ async fn job( { Ok(resp) => resp.into_inner(), Err(CommandError::NotFound) => { - let session = Session::new(SessionId::new()); + let session = Session::new(SessionId::random()); with_login(ctx, client, async || { client .session_start() @@ -900,6 +1045,7 @@ async fn job( job_id.to_owned(), command, *interactive, + target.clone(), )); pin!(sign); ctx.job_signing_started(&job_id); @@ -932,20 +1078,34 @@ async fn job( Ok(()) } - (JobCommand::Status { job_id }, Some(client)) => job_status(ctx, client, &job_id).await, + (JobCommand::Status { job_id, full, wait }, Some(client)) => { + let style = if full { + StatusDisplayStyle::Full + } else { + StatusDisplayStyle::Short + }; + if wait { + let status = job_watch(ctx, client, &job_id).await?; + ctx.job_status(&job_id, &status, style); + Ok(()) + } else { + job_status(ctx, client, &job_id, style).await + } + } (JobCommand::Stdout { target, output }, Some(client)) => { - let target = resolve_target(ctx, client, &target).await?; job_output(ctx, client, &target, Stdout, output).await } (JobCommand::Stderr { target, output }, Some(client)) => { - let target = resolve_target(ctx, client, &target).await?; job_output(ctx, client, &target, Stderr, output).await } (JobCommand::Attach { job_id, target }, Some(client)) => { - let target = resolve_target(ctx, client, &target).await?; + let target = match &target { + TargetArg::Target(target) => resolve_target(client, target).await?, + TargetArg::Serial(serial) => resolve_serial(ctx, client, &job_id, serial).await?, + }; job_attach(ctx, client, &job_id, &target).await?; Ok(()) } @@ -965,7 +1125,7 @@ async fn job( let status = job_status_try_from_json_map(job) .map_err(CommandError::BaseboardIdParseError)?; if let Some(s) = status.values().next() { - ctx.job_status(s.job_id(), &status); + ctx.job_status(s.job_id(), &status, StatusDisplayStyle::Short); } } Ok(()) @@ -981,9 +1141,9 @@ async fn job_start( job: SignedJob, start_args: JobStartArgs, ) -> Result<(), CommandError> { - // Eventually, the target will be embedded in the signed job request. - // But for now, just get it from the server. - let target = resolve_target(ctx, client, "*").await?; + // Resolve the signed request's target to one sled to watch and + // fetch output from. + let target = resolve_target(client, job.payload().target()).await?; // Set up the job request and parameters. let job_id = job.job_id().to_owned(); @@ -1033,40 +1193,66 @@ async fn job_start( start.await?; ctx.job_started(&job); match job_attach(ctx, client, &job_id, &target).await { - Ok(()) | Err(CommandError::NotFound) => job_status(ctx, client, &job_id).await?, + Ok(()) | Err(CommandError::NotFound) => { + job_status(ctx, client, &job_id, StatusDisplayStyle::Short).await? + } Err(error) => return Err(error), } } else if wait.is_some() { - let mut interval = interval(JOB_START_UPDATE_INTERVAL); + // Watch the whole rack while the start request runs, and keep + // watching until the job settles everywhere. + ctx.job_watch_started(&job_id); + let mut ticker = interval(WATCH_INTERVAL); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut last = JobStatusMap::new(); + let mut sleds = 0; + let mut quiet = 0; + let mut polls = 0; + let mut started = false; let mut stopped = false; - interval.set_missed_tick_behavior(MissedTickBehavior::Skip); - ctx.job_polling_started(&job_id, interval.period()); - loop { + let status = loop { select! { // Wait for the start request to finish. - start_result = &mut start => { - if !stopped { - ctx.job_polling_finished(&job_id); + start_result = &mut start, if !started => { + if let Err(error) = start_result { + ctx.job_watch_finished(&job_id); + return Err(error); } - start_result?; ctx.job_started(&job); - break; + started = true; } - // Periodically update the spinner. - _ = interval.tick() => { - ctx.job_polling_update(&job_id); + _ = ticker.tick() => { + last = match job_status_map(ctx, client, &job_id).await { + Ok(status) => status, + // The job may not be visible anywhere yet. + Err(CommandError::NotFound) => JobStatusMap::new(), + Err(error) => { + ctx.job_watch_finished(&job_id); + return Err(error); + } + }; + ctx.job_watch_update(&last); + polls += 1; + if started && watch_done(polls, sleds, &last, &mut quiet) { + break last; + } + sleds = last.len(); } - // Stop the job on interrupt, but don't break out of - // the select loop; we must wait for the start future - // to resolve. But there is a race here with the start, - // so retry the stop a few times if needed. - _ = ctrl_c(), if !stopped => { + // While the job runs, an interrupt stops it but keeps + // watching: the start future must still resolve, and the + // stops are worth seeing. There is a race with the start, + // so retry the stop a few times if needed. Once the job + // has stopped, or after a first interrupt, an interrupt + // just ends the watch. + _ = ctrl_c() => { + if started || stopped { + break last; + } for _ in 0..3 { match job_stop(ctx, client, &job_id).await { Ok(_) => { - ctx.job_polling_finished(&job_id); ctx.job_stopped(&job_id); stopped = true; break; @@ -1074,20 +1260,22 @@ async fn job_start( Err(CommandError::NotFound) => { sleep(JOB_STOP_RETRY_INTERVAL).await; } - Err(error) => return Err(error), + Err(error) => { + ctx.job_watch_finished(&job_id); + return Err(error); + } } } } } - } - - // Show the job status and output. - job_status(ctx, client, &job_id).await?; + }; + ctx.job_watch_finished(&job_id); + ctx.job_status(&job_id, &status, StatusDisplayStyle::Short); for stream in [Stdout, Stderr] { - match with_login(ctx, client, async || { + match with_login_via(ctx, client, Some(&target), async || { client .job_output() - .job_id(&job_id) + .job_id(job_id) .target(target.to_string()) .stream(stream) .send() @@ -1130,17 +1318,79 @@ async fn job_status( ctx: &mut impl CommandContext, client: &Client, job_id: &JobId, + style: StatusDisplayStyle, ) -> Result<(), CommandError> { + let status = job_status_map(ctx, client, job_id).await?; + ctx.job_status(job_id, &status, style); + Ok(()) +} + +/// Fetch a job's rack-wide status map. +async fn job_status_map( + ctx: &mut impl CommandContext, + client: &Client, + job_id: &JobId, +) -> Result { let status = with_login(ctx, client, async || { client.job_status().job_id(job_id).send().await }) .await? .into_inner(); - ctx.job_status( - job_id, - &job_status_try_from_json_map(status).map_err(CommandError::BaseboardIdParseError)?, - ); - Ok(()) + job_status_try_from_json_map(status).map_err(CommandError::BaseboardIdParseError) +} + +/// How often a watched job's status is refreshed. +const WATCH_INTERVAL: Duration = Duration::from_secs(1); + +/// Watch a job's status across the rack, one live line per sled, until +/// it settles or the user interrupts. Returns the last status map. +async fn job_watch( + ctx: &mut impl CommandContext, + client: &Client, + job_id: &JobId, +) -> Result { + ctx.job_watch_started(job_id); + let mut sleds = 0; + let mut quiet = 0; + let mut polls = 0; + let status = loop { + let status = match job_status_map(ctx, client, job_id).await { + Ok(status) => status, + Err(error) => { + ctx.job_watch_finished(job_id); + return Err(error); + } + }; + ctx.job_watch_update(&status); + polls += 1; + if watch_done(polls, sleds, &status, &mut quiet) { + break status; + } + sleds = status.len(); + select! { + _ = sleep(WATCH_INTERVAL) => {} + _ = ctrl_c() => break status, + } + }; + ctx.job_watch_finished(job_id); + Ok(status) +} + +/// The fewest polls a watch may run: gossip needs a few seconds to +/// fan a job out across the rack, so a map that looks settled early +/// is likely still missing sleds. +const WATCH_MIN_POLLS: usize = 5; + +/// A watched job has settled once the watch is old enough for gossip +/// to have named every sled, every known sled reports a terminal +/// status, and the set of sleds has been stable for a couple of polls. +fn watch_done(polls: usize, sleds: usize, status: &JobStatusMap, quiet: &mut usize) -> bool { + if !status.is_empty() && status.len() == sleds && status.values().all(JobStatus::is_terminal) { + *quiet += 1; + } else { + *quiet = 0; + } + polls >= WATCH_MIN_POLLS && *quiet >= 2 } /// Stream some bytes into a vector. @@ -1177,6 +1427,42 @@ type FutureChunk<'a> = dyn Future> + Send + /// Download job output. async fn job_output( + ctx: &mut impl CommandContext, + client: &Client, + target: &TargetArg, + stream: JobOutputStream, + args: JobOutput, +) -> Result<(), CommandError> { + let target = match target { + TargetArg::Serial(serial) => { + let baseboard = resolve_serial(ctx, client, &args.job_id, serial).await?; + return job_output_from(ctx, client, &baseboard, stream, args).await; + } + TargetArg::Target(target) => target, + }; + if !target.is_all() { + let baseboard = resolve_target(client, target).await?; + return job_output_from(ctx, client, &baseboard, stream, args).await; + } + if args.binary || args.file.is_some() { + return Err(CommandError::OutputNeedsTarget); + } + + // Fetch output from every sled with a recorded status. + let status = job_status_map(ctx, client, &args.job_id).await?; + if status.is_empty() { + return Err(CommandError::NotFound); + } + for baseboard in status.keys() { + ctx.job_output_target(baseboard); + if let Err(error) = job_output_from(ctx, client, baseboard, stream, args.clone()).await { + let _ = ctx.job_error(error); + } + } + Ok(()) +} + +async fn job_output_from( ctx: &mut impl CommandContext, client: &Client, target: &BaseboardId, @@ -1192,8 +1478,8 @@ async fn job_output( ) -> Result<(), CommandError> { // Fetch job status for output length and hash. let status = job_status_try_from_json_map( - with_login(ctx, client, async || { - client.job_status().job_id(&job_id).send().await + with_login_via(ctx, client, Some(target), async || { + client.job_status().job_id(job_id).send().await }) .await? .into_inner(), @@ -1295,11 +1581,11 @@ async fn job_output( } else { // Download and print the output all at once. If hash verification // fails here, do not print any output. - let byte_stream = with_login(ctx, client, { + let byte_stream = with_login_via(ctx, client, Some(target), { async || { client .job_output() - .job_id(&job_id) + .job_id(job_id) .target(target.to_string()) .stream(stream) .send() @@ -1385,7 +1671,7 @@ async fn job_attach( job_id: &JobId, target: &BaseboardId, ) -> Result<(), CommandError> { - match with_login(ctx, client, async || { + match with_login_via(ctx, client, Some(target), async || { client .job_attach() .job_id(job_id) @@ -1409,27 +1695,72 @@ async fn job_attach( } } -async fn resolve_target( +/// Resolve a target to the baseboard of one sled it names. A single +/// baseboard resolves locally. Anything else asks `/target`, routed +/// by the expression, so a proxy resolves cubbies and `*` means the +/// handling sled. +async fn resolve_target(client: &Client, target: &Target) -> Result { + if let Target::Sleds(sleds) = target + && let [SledId::Baseboard(baseboard)] = sleds.as_slice() + { + return Ok(baseboard.clone()); + } + let mut request = client.target(); + if !target.is_all() { + request = request.via(target.to_string()); + } + Ok(request.send().await?.into_inner()) +} + +/// A target expression, or a bare serial number to resolve against +/// the sleds that have a status for the job at hand. +#[derive(Clone, Debug)] +pub enum TargetArg { + Target(Target), + Serial(String), +} + +impl FromStr for TargetArg { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + match s.parse() { + Ok(target) => Ok(Self::Target(target)), + Err(_) if !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()) => { + Ok(Self::Serial(s.to_owned())) + } + Err(err) => Err(err), + } + } +} + +/// Resolve a bare serial number against the sleds that have a status +/// for a job. +async fn resolve_serial( ctx: &mut impl CommandContext, client: &Client, - target: &str, + job_id: &JobId, + serial: &str, ) -> Result { - // Eventually, "*" will mean "all sleds", but for now - // we take it as "the current sled". - Ok(if target == "*" { - with_login(ctx, client, async || client.target().send().await) - .await? - .into_inner() - } else { - target - .parse() - .map_err(CommandError::BaseboardIdParseError)? - }) + let status = job_status_map(ctx, client, job_id).await?; + let mut matches = status + .keys() + .filter(|b| b.serial_number.eq_ignore_ascii_case(serial)); + match (matches.next(), matches.next()) { + (Some(baseboard), None) => Ok(baseboard.clone()), + (None, _) => Err(CommandError::UnknownSerial { + serial: serial.to_owned(), + job_id: job_id.to_owned(), + }), + (Some(_), Some(_)) => Err(CommandError::AmbiguousSerial(serial.to_owned())), + } } /// What went wrong parsing, preparing, or executing a client command. #[derive(Debug, Error)] pub enum CommandError { + #[error("❌ Serial `{0}` matches more than one sled, use a full baseboard ID")] + AmbiguousSerial(String), #[error("❌ Authentication error")] Authn(#[from] AuthnError), #[error("❌ Canceled")] @@ -1506,11 +1837,15 @@ pub enum CommandError { expected: JobOutputHash, received: JobOutputHash, }, + #[error("❌ `--binary` and `--file` need a specific `--target`")] + OutputNeedsTarget, #[cfg(feature = "permslip")] #[error("❌ permslip error: {0}")] Permslip(#[from] PermslipError), #[error("❌ Job process error: {0}")] Process(#[from] sush_common::jobs::ProcessError), + #[error("❌ Proxy TLS error: {0}")] + ProxyTls(#[from] tls::ProxyTlsError), #[error("👋 Goodbye!")] Quit, #[error("❌ {0}")] @@ -1532,6 +1867,8 @@ pub enum CommandError { TimedOut, #[error("❌ Too much output to display on terminal, try `--file`")] TooMuchOutput, + #[error("❌ No sled with serial `{serial}` has a status for job `{job_id}`")] + UnknownSerial { serial: String, job_id: JobId }, #[error("❌ Chain root does not match any supplied root certificate")] UntrustedRoot, #[error("❌ Can't start interactive session: {0}")] diff --git a/client/src/context.rs b/client/src/context.rs index 281a32c..b8d6bbe 100644 --- a/client/src/context.rs +++ b/client/src/context.rs @@ -8,9 +8,9 @@ use std::fmt; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; use clap::ValueEnum; +use sled_hardware_types::BaseboardId; use x509_cert::Certificate; use sush_common::authn::{BoundRequest, Credentials, Identity, RequestKey}; @@ -62,6 +62,14 @@ pub enum OutputFormat { Json, } +/// How much of a job's per-sled status to show. +#[derive(Clone, Copy, Debug, Default)] +pub enum StatusDisplayStyle { + #[default] + Short, + Full, +} + impl OutputFormat { pub fn as_str(&self) -> &'static str { match self { @@ -116,6 +124,7 @@ pub trait CommandContext: Clone + Send + Sync { fn job_stopped(&mut self, id: &JobId); fn job_error(&mut self, error: CommandError) -> CommandError; fn job_output(&mut self, id: &JobId, stream: JobOutputStream, output: &[u8], binary: bool); + fn job_output_target(&mut self, target: &BaseboardId); fn job_output_started( &mut self, id: &JobId, @@ -125,16 +134,16 @@ pub trait CommandContext: Clone + Send + Sync { ); fn job_output_update(&mut self, id: &JobId, stream: JobOutputStream, bytes: u64); fn job_output_finished(&mut self, id: &JobId, stream: JobOutputStream, stage: Option<&str>); - fn job_polling_started(&mut self, id: &JobId, duration: Duration); - fn job_polling_update(&mut self, id: &JobId); - fn job_polling_finished(&mut self, id: &JobId); + fn job_watch_started(&mut self, id: &JobId); + fn job_watch_update(&mut self, status: &JobStatusMap); + fn job_watch_finished(&mut self, id: &JobId); fn job_attached(&mut self, id: &JobId); fn job_detached(&mut self, id: &JobId); fn job_signing_started(&mut self, id: &JobId); fn job_signing_update(&mut self, id: &JobId); fn job_signing_finished(&mut self, id: &JobId); fn job_signed(&mut self, job: &SignedJob, show: bool); - fn job_status(&mut self, id: &JobId, status: &JobStatusMap); + fn job_status(&mut self, id: &JobId, status: &JobStatusMap, style: StatusDisplayStyle); fn read_signed_job(&mut self) -> Result; // SSH agent and identity diff --git a/client/src/identity.rs b/client/src/identity.rs index 0ebee93..e28a4c7 100644 --- a/client/src/identity.rs +++ b/client/src/identity.rs @@ -173,7 +173,7 @@ impl IdentityError { mod test { use std::time::Duration; - use sush_common::codephrases::{PHRASE_WORDS_ID, WORD_SEPARATOR, generate_id}; + use sush_common::codephrases::Codephrase; use tempfile::TempDir; use tokio::process::Command; @@ -224,12 +224,9 @@ mod test { let mut agent = SshAgentConnection::connect(&sock).await.unwrap(); for key in agent.list_identities().await.unwrap() { - let key_id = key.key_id().unwrap(); - assert_eq!(key_id.split(WORD_SEPARATOR).count(), PHRASE_WORDS_ID); - - let nonce = generate_id(); - let signature = agent.sign_with(&key, nonce.as_bytes()).await.unwrap(); - key.verify(nonce.as_bytes(), &signature).unwrap(); + let nonce = Codephrase::random(); + let signature = agent.sign_with(&key, &nonce.to_be_bytes()).await.unwrap(); + key.verify(&nonce.to_be_bytes(), &signature).unwrap(); } agent_process diff --git a/client/src/lib.rs b/client/src/lib.rs index 32d7a18..bce029b 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -18,6 +18,7 @@ pub mod interactive; #[cfg(feature = "permslip")] pub mod permslip; pub mod repl; +pub mod tls; /// Authorization state shared between the command context and the /// client's pre-send hook, which signs every request with the current diff --git a/client/src/permslip.rs b/client/src/permslip.rs index bc210ed..8be7dbf 100644 --- a/client/src/permslip.rs +++ b/client/src/permslip.rs @@ -34,7 +34,12 @@ impl PermslipSigner { let token = tokens.token().await.map_err(PermslipError::token)?; builder = builder.token(token.into_header_value().map_err(PermslipError::token)?); Ok(Self { - client: Client::new_with_client(url, builder.build()?), + client: Client::new_with_client( + url, + builder + .build() + .map_err(|err| PermslipError::Client(err.to_string()))?, + ), key_name: key_name.as_ref().to_owned(), }) } @@ -123,8 +128,6 @@ pub enum PermslipError { Key(#[from] KeyError), #[error(transparent)] Pem(#[from] pem_rfc7468::Error), - #[error(transparent)] - Reqwest(#[from] reqwest::Error), #[error("authentication token: {0}")] Token(String), } diff --git a/client/src/repl.rs b/client/src/repl.rs index 417cde4..2e101ad 100644 --- a/client/src/repl.rs +++ b/client/src/repl.rs @@ -9,12 +9,12 @@ use std::env; use std::ffi::OsString; use std::path::Path; -use std::time::Duration; use clap::Parser; use rustyline::DefaultEditor; use rustyline::error::ReadlineError; use shlex::split as split_command; +use sled_hardware_types::BaseboardId; use x509_cert::Certificate; use xdg::BaseDirectories; @@ -29,7 +29,7 @@ use crate::commands::{ ClientCommand, CommandError, GlobalArgs, SSH_AUTH_SOCK, SUSH_JOB_ID, SUSH_KEY_ID, SUSH_OUTPUT_FORMAT, SUSH_URL, }; -use crate::context::{CommandContext, OutputFormat}; +use crate::context::{CommandContext, OutputFormat, StatusDisplayStyle}; use crate::{AuthzSigner, Client}; const PREFIX: &str = "sush"; @@ -164,6 +164,7 @@ impl CommandContext for Repl { offline, ssh_auth_sock, ssh_key_id, + proxy_roots: _, } = args; if json { output = Some(OutputFormat::Json); @@ -259,6 +260,10 @@ impl CommandContext for Repl { self.cli.job_error(error) } + fn job_output_target(&mut self, target: &BaseboardId) { + self.cli.job_output_target(target) + } + fn job_output(&mut self, job_id: &JobId, stream: JobOutputStream, output: &[u8], binary: bool) { self.set_job_id(Some(job_id.to_owned())); self.cli.job_output(job_id, stream, output, binary); @@ -288,18 +293,6 @@ impl CommandContext for Repl { self.cli.job_output_finished(job_id, stream, stage); } - fn job_polling_started(&mut self, job_id: &JobId, duration: Duration) { - self.cli.job_polling_started(job_id, duration); - } - - fn job_polling_update(&mut self, job_id: &JobId) { - self.cli.job_polling_update(job_id); - } - - fn job_polling_finished(&mut self, job_id: &JobId) { - self.cli.job_polling_finished(job_id); - } - fn job_attached(&mut self, job_id: &JobId) { self.cli.job_attached(job_id); } @@ -324,9 +317,21 @@ impl CommandContext for Repl { self.cli.job_signed(job, show); } - fn job_status(&mut self, job_id: &JobId, status: &JobStatusMap) { + fn job_watch_started(&mut self, job_id: &JobId) { + self.cli.job_watch_started(job_id) + } + + fn job_watch_update(&mut self, status: &JobStatusMap) { + self.cli.job_watch_update(status) + } + + fn job_watch_finished(&mut self, job_id: &JobId) { + self.cli.job_watch_finished(job_id) + } + + fn job_status(&mut self, job_id: &JobId, status: &JobStatusMap, style: StatusDisplayStyle) { self.set_job_id(Some(job_id.to_owned())); - self.cli.job_status(job_id, status); + self.cli.job_status(job_id, status, style); } fn read_signed_job(&mut self) -> Result { diff --git a/client/src/tls.rs b/client/src/tls.rs new file mode 100644 index 0000000..3899e6e --- /dev/null +++ b/client/src/tls.rs @@ -0,0 +1,149 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! TLS for reaching a proxy, whose certificate chain leads to the +//! sled's platform identity, verified against the platform PKI roots. +//! +//! Verification proves only that the server holds a key some RoT +//! vouched for. TLS only provides transport privacy for job traffic. +//! We do not support expiration, revocation, or server-name binding. + +use std::sync::Arc; +use std::time::Duration; + +use ed25519_dalek::{Signature, VerifyingKey}; +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::version::TLS13; +use rustls::{CertificateError, ClientConfig, DigitallySignedStruct, SignatureScheme}; +use sha3::{Digest as _, Sha3_256}; +use slog::{Discard, Logger, o}; +use sprockets_tls::keys::RotCertVerifier; +use thiserror::Error; +use x509_cert::Certificate; +use x509_cert::der::{Decode as _, DecodePem as _, Encode as _}; + +/// The same request timeout as the generated default client. +const TIMEOUT: Duration = Duration::from_secs(600); + +/// The platform identity roots baked into the client: the same +/// idcerts the sled OS ships. +const PLATFORM_ROOTS: &[&[u8]] = &[ + include_bytes!("../certs/staging.pem"), + include_bytes!("../certs/production.pem"), +]; + +#[derive(Debug, Error)] +pub enum ProxyTlsError { + #[error("certificate: {0}")] + Der(#[from] x509_cert::der::Error), + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error("TLS configuration: {0}")] + Rustls(#[from] rustls::Error), + #[error("platform identity verifier: {0}")] + Verifier(String), +} + +/// The baked-in platform roots. +pub fn platform_roots() -> Result, ProxyTlsError> { + PLATFORM_ROOTS + .iter() + .map(|pem| Ok(Certificate::from_pem(pem)?)) + .collect() +} + +/// A `reqwest` client that accepts servers whose certificate chains +/// to one of `roots`. +pub fn client(roots: Vec) -> Result { + let inner = RotCertVerifier::new(roots, Logger::root(Discard, o!())) + .map_err(|err| ProxyTlsError::Verifier(err.to_string()))?; + let config = ClientConfig::builder_with_provider(Arc::new(sprockets_tls::crypto_provider())) + .with_protocol_versions(&[&TLS13])? + .dangerous() + .with_custom_certificate_verifier(Arc::new(PlatformVerifier { inner })) + .with_no_client_auth(); + Ok(reqwest::Client::builder() + .use_preconfigured_tls(config) + .timeout(TIMEOUT) + .build()?) +} + +/// Accept a certificate chain that is the platform identity itself, +/// or an ephemeral leaf the platform identity vouched for once, in +/// the RoT's signing convention: Ed25519 over the SHA3-256 digest. +/// Delegation is one level deep by construction; a delegated leaf +/// cannot vouch for further certificates. +#[derive(Debug)] +struct PlatformVerifier { + inner: RotCertVerifier, +} + +impl ServerCertVerifier for PlatformVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + if self.inner.verify_cert(end_entity, intermediates).is_ok() { + return Ok(ServerCertVerified::assertion()); + } + let [platform, rest @ ..] = intermediates else { + return Err(rustls::Error::InvalidCertificate( + CertificateError::UnknownIssuer, + )); + }; + self.inner.verify_cert(platform, rest)?; + verify_delegated_leaf(end_entity, platform)?; + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls12_signature(message, cert, dss) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + self.inner.verify_tls13_signature(message, cert, dss) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +/// Verify that `platform` signed `leaf`'s TBS certificate. +fn verify_delegated_leaf( + leaf: &CertificateDer<'_>, + platform: &CertificateDer<'_>, +) -> Result<(), rustls::Error> { + let encoding = |_| rustls::Error::InvalidCertificate(CertificateError::BadEncoding); + let bad = rustls::Error::InvalidCertificate(CertificateError::BadSignature); + let leaf = Certificate::from_der(leaf).map_err(encoding)?; + let platform = Certificate::from_der(platform).map_err(encoding)?; + let tbs = leaf.tbs_certificate.to_der().map_err(encoding)?; + let key: [u8; 32] = platform + .tbs_certificate + .subject_public_key_info + .subject_public_key + .raw_bytes() + .try_into() + .map_err(|_| bad.clone())?; + let key = VerifyingKey::from_bytes(&key).map_err(|_| bad.clone())?; + let signature = Signature::from_slice(leaf.signature.raw_bytes()).map_err(|_| bad.clone())?; + key.verify_strict(&Sha3_256::digest(&tbs), &signature) + .map_err(|_| bad) +} diff --git a/common/src/authn.rs b/common/src/authn.rs index 54ca38f..3ccd184 100644 --- a/common/src/authn.rs +++ b/common/src/authn.rs @@ -30,8 +30,9 @@ use std::str::FromStr; use std::time::Duration; use blake3::Hasher; +use borsh::{BorshDeserialize, BorshSerialize}; use chrono::{DateTime, Utc}; -use crypto_bigint::{ArrayEncoding as _, U256}; +use crypto_bigint::U256; use ed25519_dalek::{Signature as Ed25519Signature, Signer as _, SigningKey, VerifyingKey}; use http::header::{HeaderValue, InvalidHeaderValue}; use rand_core::OsRng; @@ -39,10 +40,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::codephrases::{ - InvalidCodephrase, WORD_SEPARATOR, codephrase, decode_phrase, generate_id, +use crate::codephrase_newtype; +use crate::codephrases::InvalidCodephrase; +use crate::keys::{ + EccR, EccS, EncodedSignature, KeyError, KeyId, Signed, SshPublicKey, ToBeSigned, Verified, }; -use crate::keys::{EncodedSignature, KeyError, KeyId, Signed, SshPublicKey, ToBeSigned, Verified}; /// The name of our custom HTTP authentication scheme. pub const AUTHN_SCHEME: &str = "Sush"; @@ -61,32 +63,22 @@ pub const IDENTITY_MAX_TTL: Duration = Duration::from_secs(8 * 3600); /// The time-to-live of a nonce. pub const NONCE_TTL: Duration = Duration::from_secs(60); -/// A unique random string. Authentication credentials have two -/// of these: one generated by the server, and one by the client. -/// This structure is agnostic to the syntax of the string. -#[derive(Clone, Debug, Deserialize, Eq, Hash, JsonSchema, PartialEq, Serialize)] -pub struct Nonce(String); - -impl Nonce { - pub fn generate() -> Self { - Self(generate_id()) - } - - pub fn as_bytes(&self) -> &[u8] { - self.0.as_bytes() - } -} - -impl From for Nonce { - fn from(nonce: String) -> Self { - Self(nonce) - } -} - -impl fmt::Display for Nonce { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } +codephrase_newtype! { + /// A unique random string. Authentication credentials have two + /// of these: one generated by the server, and one by the client. + /// This structure is agnostic to the syntax of the string. + #[derive( + BorshDeserialize, + BorshSerialize, + Clone, + Deserialize, + Eq, + Hash, + JsonSchema, + PartialEq, + Serialize, + )] + pub struct Nonce = Truncated; } /// Authentication challenge. @@ -122,7 +114,7 @@ impl FromStr for Challenge { /// Parse a server challenge, e.g., `Sush nonce=foo-bar-baz`. fn from_str(s: &str) -> Result { let mut params = AuthnParams::from_str(s)?; - let nonce = params.get("nonce")?; + let nonce = params.parse("nonce")?; params.finished()?; Ok(Self::new(Nonce(nonce))) } @@ -142,20 +134,19 @@ impl RequestKey { } pub fn verifier(&self) -> RequestVerifier { - RequestVerifier(self.0.verifying_key()) + RequestVerifier::from_be_bytes(*self.0.verifying_key().as_bytes()) } /// Sign `request`, yielding the credentials that authorize it. pub fn bind(&self, key_id: KeyId, nonce: Nonce, request: &BoundRequest) -> BoundCredentials { let signature = self.0.sign(&request.to_be_signed(&key_id, &nonce)); - let phrase = |bytes: &[u8]| codephrase(U256::from_be_slice(bytes)).join(WORD_SEPARATOR); BoundCredentials { key_id, nonce, seq: request.seq, signature: EncodedSignature { - r: phrase(&signature.r_bytes()[..]), - s: phrase(&signature.s_bytes()[..]), + r: EccR::from_be_bytes(*signature.r_bytes()), + s: EccS::from_be_bytes(*signature.s_bytes()), flags: 0, counter: 0, }, @@ -163,15 +154,14 @@ impl RequestKey { } } -/// The server half of an ephemeral request-signing key. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RequestVerifier(VerifyingKey); +codephrase_newtype! { + /// The server half of an ephemeral request-signing key. + #[derive(Clone, Eq, PartialEq, BorshSerialize, BorshDeserialize)] + pub struct RequestVerifier = Full; -impl RequestVerifier { - fn as_bytes(&self) -> &[u8] { - self.0.as_bytes() - } +} +impl RequestVerifier { /// Verify that `credentials` sign `request`. pub fn verify( &self, @@ -181,42 +171,22 @@ impl RequestVerifier { if request.seq != credentials.seq { return Err(AuthnError::InvalidSignature); } - let half = |phrase: &str| -> Result<[u8; 32], AuthnError> { - Ok(decode_phrase(phrase)?.to_be_byte_array().into()) - }; let EncodedSignature { r, s, .. } = &credentials.signature; - let signature = Ed25519Signature::from_components(half(r)?, half(s)?); - self.0 - .verify_strict( - &request.to_be_signed(&credentials.key_id, &credentials.nonce), - &signature, - ) - .map_err(|_| AuthnError::InvalidSignature) - } -} - -impl fmt::Display for RequestVerifier { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}", - codephrase(U256::from_be_slice(self.as_bytes())).join(WORD_SEPARATOR) - ) - } -} - -impl FromStr for RequestVerifier { - type Err = AuthnError; + let signature = Ed25519Signature::from_components(r.to_be_bytes(), s.to_be_bytes()); - fn from_str(s: &str) -> Result { - let bytes = decode_phrase(s)?.to_be_byte_array(); - let key = VerifyingKey::from_bytes(&bytes.into()).map_err(|_| AuthnError::InvalidKey)?; + let key = + VerifyingKey::from_bytes(&self.to_be_bytes()).map_err(|_| AuthnError::InvalidKey)?; // A small-order key verifies anything, degrading the session // back to a bearer credential. if key.is_weak() { return Err(AuthnError::InvalidKey); } - Ok(Self(key)) + + key.verify_strict( + &request.to_be_signed(&credentials.key_id, &credentials.nonce), + &signature, + ) + .map_err(|_| AuthnError::InvalidSignature) } } @@ -261,8 +231,8 @@ impl BoundRequest { seq, } = self; hash(Self::TYPE_NAME); - hash(key_id.as_bytes()); - hash(nonce.as_bytes()); + hash(&key_id.to_be_bytes()); + hash(&nonce.to_be_bytes()); hash(method.as_bytes()); hash(target.as_bytes()); hash(&seq.to_be_bytes()); @@ -304,12 +274,13 @@ impl FromStr for BoundCredentials { fn from_str(s: &str) -> Result { let mut params = AuthnParams::from_str(s)?; let creds = Self { - key_id: params.get("key-id")?, - nonce: params.get("nonce")?, + key_id: params.parse("key-id")?, + nonce: params.parse("nonce")?, seq: params.parse("seq")?, signature: EncodedSignature { - r: params.get("r")?, - s: params.get("s")?, + r: params.parse("r")?, + + s: params.parse("s")?, flags: 0, counter: 0, }, @@ -349,6 +320,12 @@ pub const SEQ_WINDOW: u64 = 256; /// concurrent requests arrive out of order without opening a replay. /// Anything older is refused. Bit `age` of `seen` marks /// `highest - age` as spent. +/// +/// Each server keeps its own windows, so a captured request may be +/// replayed to a server that has not spent its sequence number yet. +/// State requests are deduplicated rack-wide by the state machine. +/// Requests served from local state (output reads, attaches) can be +/// re-issued to any sled their signed target resolves to. #[derive(Clone, Copy, Debug, Default)] pub struct SeqWindow { highest: u64, @@ -381,20 +358,23 @@ impl SeqWindow { /// Response to an authentication challenge, containing the server-chosen /// nonce and a fresh client-chosen nonce. This is the structure that is /// signed and verified as authentication credentials. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] pub struct ChallengeResponse { nonce: Nonce, cnonce: Nonce, epk: RequestVerifier, } +/// Signed authentication evidence, self-certifying to any verifier. +pub type SignedLogin = Signed; + impl ChallengeResponse { const TYPE_NAME: &[u8] = b"sush_common::authn::ChallengeResponse"; pub fn new(challenge: Challenge, epk: RequestVerifier) -> Self { Self { nonce: challenge.nonce, - cnonce: Nonce::generate(), + cnonce: Nonce::random(), epk, } } @@ -415,9 +395,9 @@ impl ToBeSigned for ChallengeResponse { let Self { nonce, cnonce, epk } = self; hash(Self::TYPE_NAME); - hash(nonce.as_bytes()); - hash(cnonce.as_bytes()); - hash(epk.as_bytes()); + hash(&nonce.to_be_bytes()); + hash(&cnonce.to_be_bytes()); + hash(&epk.to_be_bytes()); hasher.finalize().as_bytes().to_vec() } } @@ -505,13 +485,13 @@ impl FromStr for Credentials { fn from_str(s: &str) -> Result { let mut params = AuthnParams::from_str(s)?; let creds = Self { - nonce: params.get("nonce")?, - cnonce: params.get("cnonce")?, - key_id: params.get("key-id")?, + nonce: params.parse("nonce")?, + cnonce: params.parse("cnonce")?, + key_id: params.parse("key-id")?, epk: params.parse("epk")?, signature: EncodedSignature { - r: params.get("r")?, - s: params.get("s")?, + r: params.parse("r")?, + s: params.parse("s")?, flags: params.parse("flags")?, counter: params.parse("counter")?, }, @@ -659,7 +639,7 @@ mod test { let mut signer = EphemeralKey::new_root(KeyType::Ed25519, name, validity).unwrap(); for _ in 0..10 { let key_id = signer.key_id().clone(); - let nonce = Nonce::generate(); + let nonce = Nonce::random(); let challenge = Challenge::new(nonce.clone()); let www_authenticate = challenge.to_string(); assert_eq!(www_authenticate, format!("Sush nonce={nonce}")); @@ -714,7 +694,11 @@ mod test { let key = RequestKey::new(); let verifier = key.verifier(); let request = BoundRequest::new("get", "/jobs?limit=1", 7); - let creds = key.bind(KeyId::from("baz".to_string()), Nonce::generate(), &request); + let creds = key.bind( + KeyId::from_str("abandon").unwrap(), + Nonce::random(), + &request, + ); assert_eq!( creds.to_string().parse::().unwrap(), creds @@ -743,25 +727,24 @@ mod test { // The identity is part of the signed material: credentials // re-labeled with another key ID or nonce do not verify. let relabeled = BoundCredentials { - key_id: KeyId::from("qux".to_string()), + key_id: KeyId::from_str("ability").unwrap(), ..creds.clone() }; assert!(verifier.verify(&request, &relabeled).is_err()); let relabeled = BoundCredentials { - nonce: Nonce::generate(), - ..creds + nonce: Nonce::random(), + ..creds.clone() }; assert!(verifier.verify(&request, &relabeled).is_err()); // A small-order ephemeral key is refused at parse. - let weak = codephrase(U256::from_be_slice(&{ + let weak = RequestVerifier::from_be_bytes({ let mut identity = [0u8; 32]; identity[0] = 1; identity - })) - .join(WORD_SEPARATOR); + }); assert!(matches!( - weak.parse::().unwrap_err(), + weak.verify(&request, &creds).unwrap_err(), AuthnError::InvalidKey )); } @@ -813,27 +796,27 @@ mod test { AuthnError::MissingParam(p) if p == "nonce" )); assert!(matches!( - Credentials::from_str("Sush nonce=foo").unwrap_err(), + Credentials::from_str("Sush nonce=abandon").unwrap_err(), AuthnError::MissingParam(p) if p == "cnonce" )); let epk = RequestKey::new().verifier(); assert!(matches!( Credentials::from_str( - "Sush nonce=foo,cnonce=bar,key-id=baz,epk=plugh,r=r,s=s,flags=0,counter=0" + "Sush nonce=abandon,cnonce=ability,key-id=able,epk=plugh,r=burger,s=burst,flags=0,counter=0" ) .unwrap_err(), AuthnError::InvalidParam )); assert!(matches!( Credentials::from_str(&format!( - "Sush nonce=foo,cnonce=bar,key-id=baz,epk={epk},r=r,s=s,flags=0,counter=foo" + "Sush nonce=abandon,cnonce=ability,key-id=able,epk={epk},r=burger,s=burst,flags=0,counter=foo" )) .unwrap_err(), AuthnError::InvalidParam )); assert!(matches!( Credentials::from_str(&format!( - "Sush nonce=foo,cnonce=bar,key-id=baz,epk={epk},r=r,s=s,flags=0,counter=0,foo=bar" + "Sush nonce=abandon,cnonce=ability,key-id=able,epk={epk},r=burger,s=burst,flags=0,counter=0,foo=bar" )) .unwrap_err(), AuthnError::TooManyParams diff --git a/common/src/borsh.rs b/common/src/borsh.rs index 1e2d4ab..9925ad0 100644 --- a/common/src/borsh.rs +++ b/common/src/borsh.rs @@ -12,7 +12,7 @@ use sled_hardware_types::BaseboardId; use x509_cert::Certificate; use x509_cert::der::{Decode as _, Encode as _}; -use crate::jobs::JobId; +use crate::targets::Target; /// Borsh-encode a [`blake3::Hash`] as its 32 raw bytes. /// @@ -61,6 +61,16 @@ pub fn borsh_de_baseboard_id(reader: &mut R) -> Result { }) } +pub fn borsh_ser_target(value: &Target, writer: &mut W) -> Result<()> { + value.to_string().serialize(writer) +} + +pub fn borsh_de_target(reader: &mut R) -> Result { + String::deserialize_reader(reader)? + .parse() + .map_err(|err| Error::new(ErrorKind::InvalidData, err)) +} + pub fn borsh_ser_cert(value: &Certificate, writer: &mut W) -> Result<()> { let der = value .to_der() @@ -76,29 +86,3 @@ pub fn borsh_de_cert(reader: &mut R) -> Result { ) }) } - -pub fn borsh_de_job_id(reader: &mut R) -> Result { - Ok(String::deserialize_reader(reader)? - .parse::() - .map_err(|_| Error::new(ErrorKind::InvalidData, "failed to deserialize job ID"))? - .to_string()) -} - -#[cfg(test)] -mod test { - use crate::jobs::SessionId; - - use super::*; - - #[test] - fn borsh_de_job_id() { - let hostile = borsh::to_vec(&"../../etc/passwd".to_string()).unwrap(); - assert!(borsh::from_slice::(&hostile).is_err()); - - let sneaky = borsh::to_vec(&"ALPHA-BRAVO".to_string()).unwrap(); - assert!(borsh::from_slice::(&sneaky).is_err()); - - let good = borsh::to_vec(&SessionId::new().first_job_id().to_string()).unwrap(); - assert!(borsh::from_slice::(&good).is_ok()); - } -} diff --git a/common/src/codephrases.rs b/common/src/codephrases.rs index 1fac573..47aa45b 100644 --- a/common/src/codephrases.rs +++ b/common/src/codephrases.rs @@ -9,8 +9,20 @@ //! that must be readily transmissible over low bandwidth channels (e.g., //! email, voice, printed or handwritten notes, etc.). -use crypto_bigint::{CheckedAdd as _, CheckedMul as _, Limb, Random as _, Reciprocal, U256}; +use std::borrow::Cow; +use std::fmt; +use std::io::{self, Read, Write}; +use std::str::FromStr; + +use borsh::{BorshDeserialize, BorshSerialize}; +use crypto_bigint::{ + ArrayEncoding as _, CheckedAdd as _, CheckedMul as _, Encoding as _, Limb, Random as _, + Reciprocal, U256, +}; use rand_core::OsRng; +use schemars::JsonSchema; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; use crate::wordlist::{WORDLIST, WORDLIST_LEN}; @@ -19,7 +31,12 @@ use crate::wordlist::{WORDLIST, WORDLIST_LEN}; /// to 2048, which gives us indexes into the BIP-39 word list. /// Since 204823 < 2256 < 204824, /// 24 words suffice to represent 256 bits with no redundancy. -pub const PHRASE_WORDS_256: usize = 24; +const PHRASE_WORDS_256: usize = 24; + +/// The BIP-39 word list contains no punctuation of any kind, so we are +/// free to use ASCII hyphen (`-`) as the default word separator. Decoding +/// will also accept arbitrary ASCII whitespace as word separators. +const WORD_SEPARATOR: &str = "-"; /// With 2048 words, an 8 word code phrase has ~88 bits of entropy, /// making it suitable for use as a unique, hard-to-guess identifier, @@ -28,104 +45,245 @@ pub const PHRASE_WORDS_256: usize = 24; /// Note that the security of the Support Shell protocol (RFD 620) does /// *not* rely on such identifiers being unguessable; it relies only on /// the strength of the signatures produced over these phrases. -pub const PHRASE_WORDS_ID: usize = 8; +const TRUNCATED_BITS: u32 = 88; -/// The BIP-39 word list contains no punctuation of any kind, so we are -/// free to use ASCII hyphen (`-`) as the default word separator. Decoding -/// will also accept arbitrary ASCII whitespace as word separators. -pub const WORD_SEPARATOR: &str = "-"; +/// Random code phrase like `abstract misery favorite ordinary moon talk`. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Codephrase(U256); -/// Decoding a phrase failed. -#[derive(Debug, Error)] -#[error("invalid or non-canonical code phrase")] -pub struct InvalidCodephrase; +impl Codephrase { + /// Generate a new random codephrase. + pub fn random() -> Self { + Self(U256::random(&mut OsRng)) + } -/// Look up a word in the word list. -#[inline] -fn word(index: usize) -> &'static str { - WORDLIST[index] + /// Turn 256 bits of entropy into a reasonably unique code phrase. + /// We use the low-order words of the full codephrase, and pad to + /// the canonical length; these phrases are intended for machine + /// consumption, and are short enough for easy transmission. + pub fn truncate(self) -> Self { + let mask = U256::from_u128(2u128.pow(TRUNCATED_BITS) - 1); + Self(self.0.bitand(&mask)) + } + + /// Construct a codephrase from its big-endian byte representation. + pub fn from_be_bytes(bytes: [u8; 32]) -> Self { + Self(U256::from_be_bytes(bytes)) + } + + /// Get the underlying big-endian byte representation of the codephrase. + pub fn to_be_bytes(&self) -> [u8; 32] { + self.0.to_be_byte_array().into() + } } -/// Find the index of a word in the word list. -#[inline] -fn index(word: &str) -> Result { - WORDLIST.binary_search(&word).map_err(|_| InvalidCodephrase) +impl fmt::Display for Codephrase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Turn 256 bits of entropy into an un-padded big-endian code phrase. + let b = Reciprocal::new(Limb(WORDLIST_LEN as u64)).expect("should have some words"); + let mut n = self.0; + let mut r; + let mut words = Vec::with_capacity(PHRASE_WORDS_256); + while n > U256::ZERO { + (n, r) = n.ct_div_rem_limb_with_reciprocal(&b); + words.push(word(r.0 as usize)); // accumulate little-endian + } + words.reverse(); // emit big-endian + + f.write_str(&words.join(WORD_SEPARATOR)) + } } -/// Turn 256 bits of entropy into an un-padded big-endian code phrase. -pub fn codephrase(value: U256) -> Vec<&'static str> { - let b = Reciprocal::new(Limb(WORDLIST_LEN as u64)).expect("should have some words"); - let mut n = value; - let mut r; - let mut words = Vec::with_capacity(PHRASE_WORDS_256); - while n > U256::ZERO { - (n, r) = n.ct_div_rem_limb_with_reciprocal(&b); - words.push(word(r.0 as usize)); // accumulate little-endian +impl fmt::Debug for Codephrase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Codephrase({self})") } - words.reverse(); // emit big-endian - words } -/// Turn 256 bits of entropy into a reasonably unique code phrase. -/// We use the low-order words of the full codephrase, and pad to -/// the canonical length; these phrases are intended for machine -/// consumption, and are short enough for easy transmission. -pub fn id_phrase(value: U256) -> Vec<&'static str> { - let mut phrase = codephrase(value); - let n = phrase.len().saturating_sub(PHRASE_WORDS_ID); - let mut phrase = phrase.split_off(n); - while phrase.len() < PHRASE_WORDS_ID { - phrase.insert(0, word(0)); // pad w/leading zeros +impl FromStr for Codephrase { + type Err = InvalidCodephrase; + + fn from_str(value: &str) -> Result { + // Decode a big endian code phrase into 256 bits of entropy. + // + // Decoding is non-injective, or "liberal" in the sense that it will + // accept non-canonical codephrases, e.g., ones with spaces instead of + // separators, words in mixed case, or without leading zeros. This is + // for the comfort of humans that may have to transmit such phrases. + // It rejects phrases that no entropy encodes to: unknown words, more + // than [`PHRASE_WORDS_256`] words, or a value of 256 bits or more. + // The empty phrase decodes to zero. Callers that must distinguish + // absent input from zero should check before decoding. + + let b = U256::from_u64(WORDLIST_LEN as u64); + let mut n = U256::ZERO; + let mut words = 0; + for word in value + .to_ascii_lowercase() + .replace(WORD_SEPARATOR, " ") + .split_ascii_whitespace() + { + words += 1; + if words > PHRASE_WORDS_256 { + return Err(InvalidCodephrase); + } + let r = U256::from_u64(index(word)? as u64); + n = Option::from(n.checked_mul(&b)).ok_or(InvalidCodephrase)?; + n = Option::from(n.checked_add(&r)).ok_or(InvalidCodephrase)?; + } + Ok(Self(n)) } - phrase } -/// Generate a code phrase for use as an identifier. -pub fn generate_id() -> String { - id_phrase(U256::random(&mut OsRng)).join(WORD_SEPARATOR) +impl Serialize for Codephrase { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } } -/// Decode a big endian code phrase into 256 bits of entropy. -/// -/// Decoding is non-injective, or "liberal" in the sense that it will -/// accept non-canonical codephrases, e.g., ones with spaces instead of -/// separators, words in mixed case, or without leading zeros. This is -/// for the comfort of humans that may have to transmit such phrases. -/// It rejects phrases that no entropy encodes to: unknown words, more -/// than [`PHRASE_WORDS_256`] words, or a value of 256 bits or more. -/// The empty phrase decodes to zero. Callers that must distinguish -/// absent input from zero should check before decoding. -pub fn decode_phrase(phrase: &str) -> Result { - let b = U256::from_u64(WORDLIST_LEN as u64); - let mut n = U256::ZERO; - let mut words = 0; - for word in phrase - .to_ascii_lowercase() - .replace(WORD_SEPARATOR, " ") - .split_ascii_whitespace() - { - words += 1; - if words > PHRASE_WORDS_256 { - return Err(InvalidCodephrase); +impl<'de> Deserialize<'de> for Codephrase { + fn deserialize>(deserializer: D) -> Result { + let string = ::deserialize(deserializer)?; + Self::from_str(&string).map_err(D::Error::custom) + } +} + +impl BorshSerialize for Codephrase { + fn serialize(&self, writer: &mut W) -> io::Result<()> { + <[u8; 32] as BorshSerialize>::serialize(&self.to_be_bytes(), writer) + } +} + +impl BorshDeserialize for Codephrase { + fn deserialize_reader(reader: &mut R) -> io::Result { + let bytes = <[u8; 32] as BorshDeserialize>::deserialize_reader(reader)?; + Ok(Self(U256::from_be_byte_array(bytes.into()))) + } +} + +// Treat a codephrase as a string in JSON schemas. +impl JsonSchema for Codephrase { + fn schema_name() -> String { + String::schema_name() + } + + fn json_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(generator) + } + + fn is_referenceable() -> bool { + String::is_referenceable() + } + + fn schema_id() -> Cow<'static, str> { + String::schema_id() + } +} + +#[macro_export] +macro_rules! codephrase_newtype { + ($(#[$meta:meta])* $vis:vis struct $name:ident = $len:ident;) => { + $(#[$meta])* + $vis struct $name($crate::codephrases::Codephrase); + + impl $name { + #[allow(unused)] + $vis fn random() -> Self { + let mut codephrase = $crate::codephrases::Codephrase::random(); + match $crate::codephrases::CodephraseLength::$len { + $crate::codephrases::CodephraseLength::Full => {} + $crate::codephrases::CodephraseLength::Truncated => { + codephrase = codephrase.truncate(); + } + } + Self(codephrase) + } + + #[allow(unused)] + $vis fn from_hash(hash: blake3::Hash) -> Self { + let mut codephrase = $crate::codephrases::Codephrase::from_be_bytes(*hash.as_bytes()); + match $crate::codephrases::CodephraseLength::$len { + $crate::codephrases::CodephraseLength::Full => {} + $crate::codephrases::CodephraseLength::Truncated => { + codephrase = codephrase.truncate(); + } + } + Self(codephrase) + } + + #[allow(unused)] + $vis fn from_be_bytes(bytes: [u8; 32]) -> Self { + let mut codephrase = $crate::codephrases::Codephrase::from_be_bytes(bytes); + match $crate::codephrases::CodephraseLength::$len { + $crate::codephrases::CodephraseLength::Full => {} + $crate::codephrases::CodephraseLength::Truncated => { + codephrase = codephrase.truncate(); + } + } + Self(codephrase) + } + + #[allow(unused)] + $vis fn to_be_bytes(&self) -> [u8; 32] { + self.0.to_be_bytes() + } + } + + impl std::fmt::Debug for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}({})", stringify!($name), self.0) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + <$crate::codephrases::Codephrase as std::fmt::Display>::fmt(&self.0, f) + } + } + + impl std::str::FromStr for $name { + type Err = $crate::codephrases::InvalidCodephrase; + + fn from_str(s: &str) -> Result<$name, Self::Err> { + Ok($name(s.parse()?)) + } } - let r = U256::from_u64(index(word)? as u64); - n = Option::from(n.checked_mul(&b)).ok_or(InvalidCodephrase)?; - n = Option::from(n.checked_add(&r)).ok_or(InvalidCodephrase)?; } - Ok(n) +} + +#[derive(Clone, Copy)] +pub enum CodephraseLength { + Full, + Truncated, +} + +/// Decoding a phrase failed. +#[derive(Debug, Error)] +#[error("invalid or non-canonical code phrase")] +pub struct InvalidCodephrase; + +/// Look up a word in the word list. +#[inline] +fn word(index: usize) -> &'static str { + WORDLIST[index] +} + +/// Find the index of a word in the word list. +#[inline] +fn index(word: &str) -> Result { + WORDLIST.binary_search(&word).map_err(|_| InvalidCodephrase) } #[cfg(test)] mod test { use super::*; - use sha2::{Digest as _, Sha256}; use std::collections::HashSet; fn round_trip(entropy: U256) -> String { - let codephrase = codephrase(entropy).join(WORD_SEPARATOR); - let decoded = decode_phrase(&codephrase).unwrap(); - assert_eq!(entropy, decoded); - codephrase + let codephrase = Codephrase::from_be_bytes(entropy.to_be_bytes()); + let decoded: Codephrase = codephrase.to_string().parse().unwrap(); + assert_eq!(codephrase, decoded); + codephrase.to_string() } #[test] @@ -158,44 +316,49 @@ mod test { #[test] fn non_phrases() { // Unknown words, wherever they appear. - assert!(decode_phrase("plugh").is_err()); - assert!(decode_phrase("abandon-plugh").is_err()); + assert!(Codephrase::from_str("plugh").is_err()); + assert!(Codephrase::from_str("abandon-plugh").is_err()); // More words than any entropy encodes to. let long = [word(0); PHRASE_WORDS_256 + 1].join(WORD_SEPARATOR); - assert!(decode_phrase(&long).is_err()); + assert!(Codephrase::from_str(&long).is_err()); // 24-word values of 256 bits or more: the smallest, exactly // 2^256, and the largest. let mut smallest = vec![word(8)]; smallest.extend([word(0); PHRASE_WORDS_256 - 1]); - assert!(decode_phrase(&smallest.join(WORD_SEPARATOR)).is_err()); + assert!(Codephrase::from_str(&smallest.join(WORD_SEPARATOR)).is_err()); let largest = [word(WORDLIST_LEN - 1); PHRASE_WORDS_256].join(WORD_SEPARATOR); - assert!(decode_phrase(&largest).is_err()); + assert!(Codephrase::from_str(&largest).is_err()); } #[test] fn constant_codephrases() { assert_eq!( - round_trip(U256::from_be_slice(&Sha256::digest("test phrase one"))), - "abstract-summer-orange-gown-urge-model-\ - exact-gorilla-outside-common-this-pepper-\ - pear-dust-minimum-black-double-recipe-\ - castle-crystal-clog-logic-delay-hamster" + round_trip(U256::from_be_slice( + blake3::hash(b"test phrase one").as_slice() + )), + "able-wet-frame-clown-gauge-gather-curious-\ + stereo-moment-moral-mirror-net-laptop-square-\ + toe-skill-upper-credit-cancel-flag-what-powder-\ + guide-hold" ); assert_eq!( - round_trip(U256::from_be_slice(&Sha256::digest("another test phrase"))), - "abstract-misery-favorite-ordinary-moon-talk-\ - write-coffee-digital-slogan-spray-angry-\ - once-jazz-random-income-garage-regret-accident-\ - file-release-deny-reward-drastic" + round_trip(U256::from_be_slice( + blake3::hash(b"another test phrase").as_slice() + )), + "about-item-skill-author-expose-assume-language-\ + mix-tornado-undo-dolphin-obtain-good-quarter-\ + poem-under-system-hybrid-foil-person-together-\ + output-exhaust-today" ); assert_eq!( - round_trip(U256::from_be_slice(&Sha256::digest("one more for luck!"))), - "able-dismiss-cost-scheme-amazing-slogan-\ - service-current-protect-feed-length-text-\ - cruise-wisdom-beauty-angle-regret-truck-\ - prosper-album-decline-wheel-pause-legend" + round_trip(U256::from_be_slice( + blake3::hash(b"one more for luck!").as_slice() + )), + "able-hazard-bread-decade-elegant-omit-ensure-\ + sudden-beef-voice-remove-nut-wish-bind-birth-b\ + leak-brush-joke-seven-amused-sunny-kite-flee-tape" ); } @@ -236,22 +399,17 @@ mod test { fn id_phrases() { let mut seen = HashSet::new(); for _ in 0..10_000 { - let id = generate_id(); - let n = id.split(WORD_SEPARATOR).count(); - assert_eq!(n, PHRASE_WORDS_ID); - assert!(seen.insert(id.clone()), "duplicate ID {id}"); - - let entropy = decode_phrase(&id).unwrap(); - round_trip(entropy); - assert_eq!(id, id_phrase(entropy).join(WORD_SEPARATOR)); + let id = Codephrase::random().truncate(); + assert!(seen.insert(id), "duplicate ID {id}"); + + let roundtrip = Codephrase::from_str(&id.to_string()).unwrap(); + assert_eq!(id, roundtrip); } } #[test] - fn id_leading_zero() { - let v = U256::ONE.shl_vartime(66); // 7 digits, since 2048⁶ = 2⁶⁶ - let id = id_phrase(v); - assert_eq!(id.len(), PHRASE_WORDS_ID); - assert_eq!(decode_phrase(&id.join(WORD_SEPARATOR)).unwrap(), v); + fn leading_zeros_after_truncation() { + let truncated = Codephrase::random().truncate(); + assert_eq!(U256::from_u8(0), truncated.0.shr(TRUNCATED_BITS as _)); } } diff --git a/common/src/jobs.rs b/common/src/jobs.rs index c54ead0..2cf079c 100644 --- a/common/src/jobs.rs +++ b/common/src/jobs.rs @@ -14,7 +14,6 @@ use blake3::{Hash, Hasher, hash}; use borsh::{BorshDeserialize, BorshSerialize}; use bytesize::GB; use chrono::{DateTime, TimeDelta, Utc}; -use crypto_bigint::U256; use rlimit::Resource; use schemars::schema::{Schema, SchemaObject}; use schemars::{JsonSchema, SchemaGenerator}; @@ -24,77 +23,30 @@ use sled_hardware_types::{BaseboardId, BaseboardIdParseError}; use thiserror::Error; use crate::borsh::{ - borsh_de_datetime, borsh_de_hash, borsh_de_job_id, borsh_ser_datetime, borsh_ser_hash, -}; -use crate::codephrases::{ - InvalidCodephrase, WORD_SEPARATOR, decode_phrase, generate_id, id_phrase, + borsh_de_datetime, borsh_de_hash, borsh_de_target, borsh_ser_datetime, borsh_ser_hash, + borsh_ser_target, }; use crate::interactive::InteractiveJobError; use crate::keys::{KeyId, Signed, ToBeSigned, Verified}; - -/// A globally unique identifier for a job within a session. -#[derive( - BorshDeserialize, - BorshSerialize, - Clone, - Debug, - Deserialize, - Eq, - Hash, - JsonSchema, - Ord, - PartialEq, - PartialOrd, - Serialize, -)] -#[serde(try_from = "String")] -pub struct JobId(#[borsh(deserialize_with = "borsh_de_job_id")] String); - -impl Deref for JobId { - type Target = str; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl fmt::Display for JobId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From<&Self> for JobId { - fn from(other: &Self) -> Self { - other.to_owned() - } -} - -impl From for JobId { - fn from(value: U256) -> Self { - Self(id_phrase(value).join(WORD_SEPARATOR)) - } -} - -impl FromStr for JobId { - type Err = InvalidCodephrase; - - fn from_str(s: &str) -> Result { - let p = id_phrase(decode_phrase(s)?).join(WORD_SEPARATOR); - if p == *s { - Ok(Self(p)) - } else { - Err(InvalidCodephrase) - } - } -} - -impl TryFrom for JobId { - type Error = InvalidCodephrase; - - fn try_from(s: String) -> Result { - JobId::from_str(&s) - } +use crate::targets::Target; + +codephrase_newtype! { + /// A globally unique identifier for a job within a session. + #[derive( + BorshDeserialize, + BorshSerialize, + Copy, + Clone, + Deserialize, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + )] + pub struct JobId = Truncated; } impl slog::Value for JobId { @@ -104,73 +56,46 @@ impl slog::Value for JobId { key: slog::Key, serializer: &mut dyn slog::Serializer, ) -> slog::Result { - serializer.emit_str(key, self) + serializer.emit_str(key, &self.0.to_string()) } } -/// A globally unique identifier for a session. -#[derive( - BorshDeserialize, - BorshSerialize, - Clone, - Debug, - Deserialize, - Eq, - Hash, - JsonSchema, - Ord, - PartialEq, - PartialOrd, - Serialize, -)] -pub struct SessionId(String); - -#[allow(clippy::new_without_default)] -impl SessionId { - pub fn new() -> Self { - Self(generate_id()) - } - - pub fn first_job_id(&self) -> JobId { - U256::from_be_slice(hash(self.0.as_bytes()).as_bytes()).into() - } - - pub fn next_job_id(&self, last_job: &LastJob) -> JobId { - U256::from_be_slice( - match last_job { - LastJob::None => hash(&[b"None", self.0.as_bytes()].concat()), - LastJob::Some(job) => hash(&[b"Some", job.to_be_signed().as_slice()].concat()), - LastJob::Burned(job_id) => hash(&[b"Burned", job_id.as_bytes()].concat()), - } - .as_bytes(), - ) - .into() +impl From<&JobId> for JobId { + fn from(value: &JobId) -> Self { + *value } } -impl Deref for SessionId { - type Target = str; - - fn deref(&self) -> &Self::Target { - &self.0 - } +codephrase_newtype! { + /// A globally unique identifier for a session. + #[derive( + BorshDeserialize, + BorshSerialize, + Copy, + Clone, + Deserialize, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + )] + pub struct SessionId = Truncated; } -impl fmt::Display for SessionId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) +impl SessionId { + pub fn first_job_id(&self) -> JobId { + JobId::from_hash(hash(&self.0.to_be_bytes())) } -} -impl From<&Self> for SessionId { - fn from(other: &Self) -> Self { - other.to_owned() - } -} - -impl> From for SessionId { - fn from(s: S) -> Self { - Self(s.as_ref().to_string()) + pub fn next_job_id(&self, last_job: &LastJob) -> JobId { + JobId::from_hash(match last_job { + LastJob::None => hash(&[b"None", self.0.to_be_bytes().as_slice()].concat()), + LastJob::Some(job) => hash(&[b"Some", job.to_be_signed().as_slice()].concat()), + LastJob::Burned(job_id) => hash(&[b"Burned", job_id.to_be_bytes().as_slice()].concat()), + }) } } @@ -208,8 +133,8 @@ impl Session { } } - pub fn session_id(&self) -> &SessionId { - &self.session_id + pub fn session_id(&self) -> SessionId { + self.session_id } pub fn started_by(&self) -> Option<&KeyId> { @@ -256,6 +181,13 @@ pub struct JobStartRequest { pub command: String, #[serde(default, skip_serializing_if = "is_false")] pub interactive: bool, + /// The sleds this job runs on. + #[borsh( + serialize_with = "borsh_ser_target", + deserialize_with = "borsh_de_target" + )] + #[serde(default, skip_serializing_if = "Target::is_all")] + pub target: Target, } fn is_false(x: &bool) -> bool { @@ -265,11 +197,17 @@ fn is_false(x: &bool) -> bool { impl JobStartRequest { const TYPE_NAME: &[u8] = b"sush_common::jobs::JobStartRequest"; - pub fn new>(job_id: JobId, command: S, interactive: bool) -> Self { + pub fn new>( + job_id: JobId, + command: S, + interactive: bool, + target: Target, + ) -> Self { Self { job_id, command: command.as_ref().to_string(), interactive, + target, } } @@ -284,6 +222,10 @@ impl JobStartRequest { pub fn interactive(&self) -> bool { self.interactive } + + pub fn target(&self) -> &Target { + &self.target + } } impl ToBeSigned for JobStartRequest { @@ -299,11 +241,13 @@ impl ToBeSigned for JobStartRequest { job_id, command, interactive, + target, } = self; hash_with_len(Self::TYPE_NAME); - hash_with_len(job_id.as_bytes()); + hash_with_len(&job_id.to_be_bytes()); hash_with_len(command.as_bytes()); hash_with_len(if *interactive { &[1] } else { &[0] }); + hash_with_len(target.to_string().as_bytes()); hasher.finalize().as_bytes().to_vec() } } @@ -471,6 +415,14 @@ impl ExecutionError { } impl JobStatus { + /// Whether this status can ever change again. + pub fn is_terminal(&self) -> bool { + matches!( + self, + Self::Cancelled { .. } | Self::Error { .. } | Self::Stopped { .. } + ) + } + /// The most recent timestamp recorded for this status. pub fn time(&self) -> DateTime { match self { diff --git a/common/src/keys.rs b/common/src/keys.rs index 59ba037..7dcdc84 100644 --- a/common/src/keys.rs +++ b/common/src/keys.rs @@ -14,20 +14,21 @@ use std::fmt; use std::ops::Deref; +use borsh::io::{Error as BorshError, ErrorKind as BorshErrorKind, Read, Write}; use borsh::{BorshDeserialize, BorshSerialize}; use bytes::{Buf as _, BufMut as _, BytesMut}; use chrono::{DateTime, Utc}; -use crypto_bigint::{ArrayEncoding as _, Random as _, U128, U256}; +use crypto_bigint::{ArrayEncoding as _, Random as _, U128}; use ed25519_dalek::{ Signature as Ed25519Signature, Signer as _, SigningKey as Ed25519SigningKey, VerifyingKey as Ed25519VerifyingKey, }; +use p256::pkcs8::{self, EncodePrivateKey as _}; use p256::{SecretKey as P256SecretKey, ecdsa}; use rand_core::OsRng; use schemars::schema::Schema; use schemars::{JsonSchema, SchemaGenerator}; use serde::{Deserialize, Serialize}; -use sha2::{Digest as _, Sha256}; use signature::Verifier; use ssh_key::{ Algorithm as SshAlgorithm, EcdsaCurve, Error as SshKeyError, Mpint, Signature as SshSignature, @@ -44,44 +45,25 @@ use x509_cert::spki::{AlgorithmIdentifierOwned, SubjectPublicKeyInfo}; use x509_cert::time::Validity; use x509_cert::{Certificate, TbsCertificate, Version}; -use crate::codephrases::{InvalidCodephrase, WORD_SEPARATOR, codephrase, decode_phrase, id_phrase}; - -/// SHA-256 of a certificate subject or an identity public key, -/// encoded as a pseudorandom code phrase for storage & transport. -#[derive( - BorshDeserialize, - BorshSerialize, - Clone, - Debug, - Deserialize, - Eq, - Hash, - JsonSchema, - Ord, - PartialEq, - PartialOrd, - Serialize, -)] -pub struct KeyId(String); - -impl Deref for KeyId { - type Target = str; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl fmt::Display for KeyId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl From for KeyId { - fn from(s: String) -> Self { - Self(s) - } +use crate::codephrases::InvalidCodephrase; + +codephrase_newtype! { + /// SHA-256 of a certificate subject or an identity public key, + /// encoded as a pseudorandom code phrase for storage & transport. + #[derive( + BorshDeserialize, + BorshSerialize, + Clone, + Deserialize, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + )] + pub struct KeyId = Truncated; } impl From<&Self> for KeyId { @@ -95,9 +77,8 @@ impl TryFrom<&Certificate> for KeyId { type Error = KeyError; fn try_from(cert: &Certificate) -> Result { - let hash = Sha256::digest(cert.tbs_certificate.subject_public_key_info.to_der()?); - let phrase = id_phrase(U256::from_be_slice(hash.as_slice())); - Ok(KeyId(phrase.join(WORD_SEPARATOR))) + let hash = blake3::hash(&cert.tbs_certificate.subject_public_key_info.to_der()?); + Ok(KeyId::from_hash(hash)) } } @@ -105,9 +86,8 @@ impl TryFrom<&ssh_key::PublicKey> for KeyId { type Error = KeyError; fn try_from(public_key: &ssh_key::PublicKey) -> Result { - let hash = Sha256::digest(public_key.to_bytes()?); - let phrase = id_phrase(U256::from_be_slice(hash.as_slice())); - Ok(KeyId(phrase.join(WORD_SEPARATOR))) + let hash = blake3::hash(&public_key.to_bytes()?); + Ok(KeyId::from_hash(hash)) } } @@ -157,6 +137,20 @@ impl Deref for SshPublicKey { } } +impl BorshSerialize for SshPublicKey { + fn serialize(&self, writer: &mut W) -> borsh::io::Result<()> { + BorshSerialize::serialize(&self.to_string(), writer) + } +} + +impl BorshDeserialize for SshPublicKey { + fn deserialize_reader(reader: &mut R) -> borsh::io::Result { + ssh_key::PublicKey::from_openssh(&String::deserialize_reader(reader)?) + .map(Self) + .map_err(|err| BorshError::new(BorshErrorKind::InvalidData, err)) + } +} + impl JsonSchema for SshPublicKey { fn schema_name() -> String { ::schema_name() @@ -171,6 +165,48 @@ impl JsonSchema for SshPublicKey { } } +codephrase_newtype! { + /// The first half of a 256 bit elliptic-curve signature: the scalar + /// `r` of an ECDSA pair, or the encoded point `R` of an Ed25519 + /// signature. It is carried as an opaque 256 bit value to which only + /// the signature algorithm assigns meaning. + #[derive( + BorshDeserialize, + BorshSerialize, + Clone, + Deserialize, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + )] + pub struct EccR = Full; +} + +codephrase_newtype! { + /// The second half of a 256 bit elliptic-curve signature: the scalar + /// `s` of an ECDSA pair, or the little-endian scalar `S` of an + /// Ed25519 signature. It is carried as an opaque 256 bit value like + /// [`EccR`]. + #[derive( + BorshDeserialize, + BorshSerialize, + Clone, + Deserialize, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + )] + pub struct EccS = Full; +} + /// Code phrase encoded signature. /// /// The decoded phrases `r` and `s` together comprise a signature @@ -194,8 +230,8 @@ impl JsonSchema for SshPublicKey { Serialize, )] pub struct EncodedSignature { - pub r: String, - pub s: String, + pub r: EccR, + pub s: EccS, #[serde(default, skip_serializing_if = "is_zero_flags")] pub flags: u8, @@ -225,8 +261,8 @@ impl EncodedSignature { flags: _, counter: _, } = self; - let r = decode_phrase(r)?.to_be_byte_array(); - let s = decode_phrase(s)?.to_be_byte_array(); + let r = r.to_be_bytes(); + let s = s.to_be_bytes(); match signature_algorithm { AlgorithmIdentifierOwned { oid: ECDSA_WITH_SHA_256, @@ -237,10 +273,7 @@ impl EncodedSignature { AlgorithmIdentifierOwned { oid: ID_ED_25519, parameters: None, - } => Ok(Signature::Ed25519(Ed25519Signature::from_components( - r.into(), - s.into(), - ))), + } => Ok(Signature::Ed25519(Ed25519Signature::from_components(r, s))), _ => Err(KeyError::InvalidPublicKeyAlgorithm), } } @@ -254,16 +287,13 @@ impl EncodedSignature { flags, counter, } = self; - let r = decode_phrase(r)?.to_be_byte_array(); - let s = decode_phrase(s)?.to_be_byte_array(); + let r = r.to_be_bytes(); + let s = s.to_be_bytes(); match algorithm { Ecdsa { curve: NistP256 } => Ok(Signature::EcdsaSha256( ecdsa::Signature::from_scalars(r, s)?, )), - Ed25519 => Ok(Signature::Ed25519(Ed25519Signature::from_components( - r.into(), - s.into(), - ))), + Ed25519 => Ok(Signature::Ed25519(Ed25519Signature::from_components(r, s))), SkEcdsaSha2NistP256 => { let mut bytes = BytesMut::new(); put_mpint(&mut bytes, r)?; @@ -312,17 +342,16 @@ impl Signature { } pub fn encode(&self) -> Result { - let codephrase = |x: U256| codephrase(x).join(WORD_SEPARATOR); match self { Self::EcdsaSha256(signature) => Ok(EncodedSignature { - r: codephrase(U256::from_be_byte_array(signature.r().to_bytes())), - s: codephrase(U256::from_be_byte_array(signature.s().to_bytes())), + r: EccR::from_be_bytes(signature.r().to_bytes().into()), + s: EccS::from_be_bytes(signature.s().to_bytes().into()), flags: 0, counter: 0, }), Self::Ed25519(signature) => Ok(EncodedSignature { - r: codephrase(U256::from_be_slice(signature.r_bytes())), - s: codephrase(U256::from_be_slice(signature.s_bytes())), + r: EccR::from_be_bytes(*signature.r_bytes()), + s: EccS::from_be_bytes(*signature.s_bytes()), flags: 0, counter: 0, }), @@ -333,8 +362,8 @@ impl Signature { let s = get_mpint(&mut signature)?; let e = || KeyError::InvalidSignatureEncoding; Ok(EncodedSignature { - r: codephrase(u256_be(r.as_positive_bytes().ok_or_else(e)?)?), - s: codephrase(u256_be(s.as_positive_bytes().ok_or_else(e)?)?), + r: EccR::from_be_bytes(u256_be(r.as_positive_bytes().ok_or_else(e)?)?), + s: EccS::from_be_bytes(u256_be(s.as_positive_bytes().ok_or_else(e)?)?), flags, counter, }) @@ -343,8 +372,8 @@ impl Signature { let (signature, flags, counter) = sk_split(signature.as_bytes())?; let signature = Ed25519Signature::from_slice(signature)?; Ok(EncodedSignature { - r: codephrase(U256::from_be_slice(signature.r_bytes())), - s: codephrase(U256::from_be_slice(signature.s_bytes())), + r: EccR::from_be_bytes(*signature.r_bytes()), + s: EccS::from_be_bytes(*signature.s_bytes()), flags, counter, }) @@ -405,13 +434,13 @@ impl Signature { /// A `U256` from up to 32 big-endian bytes. An mpint's minimal /// encoding may carry fewer, which `U256::from_be_slice` refuses. -fn u256_be(bytes: &[u8]) -> Result { +fn u256_be(bytes: &[u8]) -> Result<[u8; 32], KeyError> { let Some(pad) = 32usize.checked_sub(bytes.len()) else { return Err(KeyError::InvalidSignatureEncoding); }; let mut buf = [0; 32]; buf[pad..].copy_from_slice(bytes); - Ok(U256::from_be_slice(&buf)) + Ok(buf) } /// Append one SSH mpint, encoded from positive big-endian bytes. @@ -769,6 +798,41 @@ impl EphemeralKey { Ok(Self { key, key_id, cert }) } + /// Ephemeral key with cert signed by an external authority, + /// e.g., a platform RoT. `sign` takes the TBS certificate DER + /// and returns raw signature bytes in whatever convention the + /// authority and its verifiers share. `signature_algorithm` is + /// the authority's claimed scheme. A nonstandard convention has + /// no identifier of its own, so the cert may not verify under + /// the algorithm it declares. + pub fn new_delegated( + key_type: KeyType, + subject: Name, + issuer: Name, + validity: Validity, + signature_algorithm: AlgorithmIdentifierOwned, + sign: impl FnOnce(&[u8]) -> Result, E>, + ) -> Result { + let key = SigningKey::new(key_type); + let tbs_certificate = Self::tbs_certificate( + &key, + Self::generate_serial_number()?, + signature_algorithm, + subject, + issuer, + validity, + ); + let tbs = tbs_certificate.to_der()?; + let signature = sign(&tbs).map_err(KeyError::signer)?; + let cert = Certificate { + signature_algorithm: tbs_certificate.signature.to_owned(), + signature: BitString::from_bytes(&signature)?, + tbs_certificate, + }; + let key_id = KeyId::try_from(&cert)?; + Ok(Self { key, key_id, cert }) + } + /// Ephemeral key with cert signed by a parent. pub async fn new_child( key_type: KeyType, @@ -806,6 +870,15 @@ impl EphemeralKey { &self.cert } + /// Export the private key as PKCS#8 PEM, e.g., for TLS. + pub fn private_key_pem(&self) -> Result { + let pem = match &self.key { + SigningKey::Ed25519(key) => key.to_pkcs8_pem(LineEnding::LF)?, + SigningKey::P256(key) => key.to_pkcs8_pem(LineEnding::LF)?, + }; + Ok(pem.to_string()) + } + pub fn subject(&self) -> Name { self.cert.tbs_certificate.subject.clone() } @@ -941,6 +1014,8 @@ pub enum KeyError { MissingCert(KeyId), #[error(transparent)] Pem(#[from] pem_rfc7468::Error), + #[error("PKCS#8 encoding error: {0}")] + Pkcs8(#[from] pkcs8::Error), #[error("Certificate for key `{0}` was revoked at {1}")] Revoked(KeyId, DateTime), #[error("Will not import a self-signed (root) certificate")] @@ -1006,10 +1081,10 @@ mod test { .unwrap(); let mut signatures = HashSet::new(); for _ in 0..100 { - let nonce = Nonce::generate(); - let signed = key.sign(nonce.as_bytes()).await.unwrap(); + let nonce = Nonce::random(); + let signed = key.sign(nonce.to_be_bytes()).await.unwrap(); assert_eq!(signed.key_id(), key.key_id()); - assert_eq!(*signed.payload(), nonce.as_bytes()); + assert_eq!(*signed.payload(), nonce.to_be_bytes()); let signature = signed.signature(); assert!(signatures.insert(signature.clone()), "duplicate signature"); let signature_string = serde_json::to_string(&signature).unwrap(); @@ -1022,7 +1097,7 @@ mod test { ); let verified = signed.verify_with_cert(key.cert()).unwrap(); assert_eq!(verified.verified_by(), key.key_id()); - assert_eq!(verified.into_payload(), nonce.as_bytes()); + assert_eq!(verified.into_payload(), nonce.to_be_bytes()); } } } diff --git a/common/src/lib.rs b/common/src/lib.rs index c65b991..da9e5b3 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -6,10 +6,12 @@ pub mod authn; pub mod borsh; +#[macro_use] pub mod codephrases; pub mod interactive; pub mod jobs; pub mod keys; +pub mod targets; pub mod wordlist; /// The file name of the OpenAPI document generated by diff --git a/common/src/targets.rs b/common/src/targets.rs new file mode 100644 index 0000000..c700346 --- /dev/null +++ b/common/src/targets.rs @@ -0,0 +1,213 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Which sleds a request names. +//! +//! One grammar serves signed job requests, the client, and the proxy: +//! `*` means every sled in the rack, and a comma-separated list names +//! sleds by baseboard ID or cubby number. Cubby numbers resolve +//! against a mapping the rack learns at runtime, so a target is +//! evaluated lazily, against what is known when it is asked. + +use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; + +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::Schema; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sled_hardware_types::BaseboardId; +use thiserror::Error; + +/// Baseboards by cubby number, as much of it as is known. +pub type Cubbies = BTreeMap; + +/// The highest cubby number in a rack. +pub const MAX_CUBBY: u8 = 31; + +/// The sleds a request names. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub enum Target { + #[default] + All, + Sleds(Vec), +} + +/// One sled, named by baseboard or by cubby. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SledId { + Baseboard(BaseboardId), + Cubby(u8), +} + +/// A target failed to parse. +#[derive(Debug, Error)] +#[error("invalid target `{0}`")] +pub struct InvalidTarget(String); + +impl Target { + /// Whether `baseboard` is targeted, as far as `cubbies` can say. + /// A cubby the mapping does not know targets nothing. + pub fn includes(&self, baseboard: &BaseboardId, cubbies: &Cubbies) -> bool { + match self { + Self::All => true, + Self::Sleds(sleds) => sleds.iter().any(|sled| match sled { + SledId::Baseboard(named) => named == baseboard, + SledId::Cubby(cubby) => cubbies.get(cubby) == Some(baseboard), + }), + } + } + + pub fn is_all(&self) -> bool { + matches!(self, Self::All) + } +} + +impl FromStr for Target { + type Err = InvalidTarget; + + fn from_str(s: &str) -> Result { + if s.trim() == "*" { + return Ok(Self::All); + } + s.split(',') + .map(|sled| sled.trim().parse()) + .collect::>() + .map(Self::Sleds) + } +} + +impl FromStr for SledId { + type Err = InvalidTarget; + + fn from_str(s: &str) -> Result { + if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) { + s.parse().ok().filter(|n| *n <= MAX_CUBBY).map(Self::Cubby) + } else { + s.parse().map(Self::Baseboard).ok() + } + .ok_or_else(|| InvalidTarget(s.to_string())) + } +} + +impl fmt::Display for Target { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::All => f.write_str("*"), + Self::Sleds(sleds) => { + for (i, sled) in sleds.iter().enumerate() { + if i > 0 { + f.write_str(",")?; + } + write!(f, "{sled}")?; + } + Ok(()) + } + } + } +} + +impl fmt::Display for SledId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Baseboard(baseboard) => write!(f, "{baseboard}"), + Self::Cubby(cubby) => write!(f, "{cubby}"), + } + } +} + +impl Serialize for Target { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Target { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(D::Error::custom) + } +} + +impl JsonSchema for Target { + fn schema_name() -> String { + ::schema_name() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + ::json_schema(generator) + } + + fn is_referenceable() -> bool { + ::is_referenceable() + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn baseboard(s: &str) -> BaseboardId { + s.parse().unwrap() + } + + #[test] + fn targets_parse() { + assert_eq!("*".parse::().unwrap(), Target::All); + assert_eq!(" * ".parse::().unwrap(), Target::All); + assert_eq!( + "14".parse::().unwrap(), + Target::Sleds(vec![SledId::Cubby(14)]), + ); + assert_eq!( + "14, 16".parse::().unwrap(), + Target::Sleds(vec![SledId::Cubby(14), SledId::Cubby(16)]), + ); + assert_eq!( + "913-0000019:BRM42220031,8".parse::().unwrap(), + Target::Sleds(vec![ + SledId::Baseboard(baseboard("913-0000019:BRM42220031")), + SledId::Cubby(8), + ]), + ); + + for bad in ["", " ", "14,,16", "no-colon", "32", "999", "-1", "*,3"] { + assert!(bad.parse::().is_err(), "`{bad}` should not parse"); + } + assert!("31".parse::().is_ok()); + } + + #[test] + fn targets_round_trip() { + for target in ["*", "14", "14,16", "913-0000019:BRM42220031,8"] { + assert_eq!(target.parse::().unwrap().to_string(), target); + } + // Display canonicalizes whitespace. + assert_eq!("14, 16".parse::().unwrap().to_string(), "14,16"); + } + + #[test] + fn targets_include() { + let brm31 = baseboard("913-0000019:BRM42220031"); + let brm40 = baseboard("913-0000019:BRM42220040"); + let cubbies = Cubbies::from([(14, brm31.clone())]); + + let all = Target::All; + assert!(all.includes(&brm31, &cubbies)); + assert!(all.includes(&brm40, &cubbies)); + + let by_baseboard: Target = "913-0000019:BRM42220031".parse().unwrap(); + assert!(by_baseboard.includes(&brm31, &cubbies)); + assert!(!by_baseboard.includes(&brm40, &cubbies)); + + let by_cubby: Target = "14,16".parse().unwrap(); + assert!(by_cubby.includes(&brm31, &cubbies)); + // Cubby 16 is not in the mapping, so it targets nothing. + assert!(!by_cubby.includes(&brm40, &cubbies)); + assert!(!by_cubby.includes(&brm40, &Cubbies::new())); + } +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 030df7c..59277fe 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.91" +channel = "1.97.1" components = ["clippy", "rustfmt"] diff --git a/server/Cargo.toml b/server/Cargo.toml index 8d4b2bd..6787f3c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -12,12 +12,14 @@ name = "sush-server" path = "src/main.rs" [features] +embedded = [] test-support = [] [dependencies] blake3.workspace = true borsh.workspace = true bytes.workspace = true +camino.workspace = true bytesize.workspace = true chrono.workspace = true clap.workspace = true @@ -28,14 +30,17 @@ http.workspace = true http-body-util.workspace = true http-range-header.workspace = true hyper.workspace = true +hyper-util.workspace = true libc.workspace = true lru.workspace = true memmap2.workspace = true p256.workspace = true +percent-encoding.workspace = true pwd.workspace = true rumors.workspace = true rand_core.workspace = true rustix.workspace = true +rustls.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true @@ -43,12 +48,14 @@ sha2.workspace = true sled-hardware-types.workspace = true slog.workspace = true slog-term.workspace = true +sprockets-tls.workspace = true ssh-key.workspace = true sush-api = { path = "../api" } sush-common = { path = "../common" } tempfile.workspace = true thiserror.workspace = true tokio.workspace = true +tokio-rustls.workspace = true tokio-stream.workspace = true tokio-util.workspace = true tokio-tungstenite.workspace = true @@ -56,4 +63,7 @@ uuid.workspace = true x509-cert.workspace = true [dev-dependencies] +attest-mock.workspace = true function_name.workspace = true +rumors = { workspace = true, features = ["conformance"] } +sprockets-tls-test-utils.workspace = true diff --git a/server/src/executor.rs b/server/src/executor.rs index 419dccb..ab96f27 100644 --- a/server/src/executor.rs +++ b/server/src/executor.rs @@ -130,9 +130,9 @@ impl Executor { }; let stop = self.shutdown.child_token(); - self.stop.insert(job_id.clone(), stop.clone()); + self.stop.insert(job_id, stop.clone()); spawn(job_spawn( - self.log.new(o!("job_id" => job_id.clone())), + self.log.new(o!("job_id" => job_id)), events, self.output_dir.clone(), verified_request, @@ -177,6 +177,7 @@ async fn job_spawn( job_id, command, interactive, + target: _, } = request.payload().clone(); let JobStartParams { limits: requested, @@ -185,7 +186,7 @@ async fn job_spawn( cols, wait: _, } = params; - let limits = requested.clone().clamp(); + let mut limits = requested.clone().clamp(); if limits != requested { warn!( log, "clamped requested job limits"; @@ -193,6 +194,19 @@ async fn job_spawn( ); } + // Ensure consistent output dirs. + let dirs = output_dir.current(); + + // Clamp max file size to output requirements. + if limits.max_fsize > dirs.max_fsize() { + warn!( + log, "clamping requested job output size limit"; + "requested" => limits.max_fsize, + "max_fsize" => dirs.max_fsize(), + ); + limits.max_fsize = dirs.max_fsize(); + } + // Report all I/O errors as job events. let io_err = |what| move |err: io::Error| ProcessError::io(what, err); macro_rules! with_io_err { @@ -210,7 +224,7 @@ async fn job_spawn( // Set up output directories and files. let dir_mode = 0o700; let file_mode = 0o600; - let job_dir = output_dir.job_output_dir(&job_id); + let job_dir = dirs.job_output_dir(&job_id); with_io_err!( DirBuilder::new() .recursive(true) @@ -219,8 +233,8 @@ async fn job_spawn( .await, format!("creating job output directory `{}`", job_dir.display()) ); - let stdout_path = output_dir.job_output_path(&job_id, Stdout); - let stderr_path = output_dir.job_output_path(&job_id, Stderr); + let stdout_path = dirs.job_output_path(&job_id, Stdout); + let stderr_path = dirs.job_output_path(&job_id, Stderr); let stdout_file = with_io_err!( OpenOptions::new() .create_new(true) @@ -358,7 +372,7 @@ async fn job_spawn( // Announce the birth of our new job! let _ = events - .send(Event::Job(JobEvent::Start(job_id.clone(), Utc::now()))) + .send(Event::Job(JobEvent::Start(job_id, Utc::now()))) .await; // Wait for the job to die and send an event when it does. @@ -367,7 +381,7 @@ async fn job_spawn( Ok((result, state)) => { let _ = events .send(Event::Job(JobEvent::Stop( - job_id.clone(), + job_id, Utc::now(), result, state, @@ -377,7 +391,7 @@ async fn job_spawn( Err(error) => { let _ = events .send(Event::Job(JobEvent::Error( - job_id.clone(), + job_id, Utc::now(), ProcessError::Join(error.to_string()), ))) diff --git a/server/src/gossip.rs b/server/src/gossip.rs new file mode 100644 index 0000000..9a83518 --- /dev/null +++ b/server/src/gossip.rs @@ -0,0 +1,366 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Converge a peer address set into one gossip universe. +//! +//! Every peer seeds its own universe at startup, so peers meet across +//! unrelated universes and their sessions fail with +//! [`Error::NetworkMismatch`]. That error carries everything both sides need +//! to agree on which universe survives, without coordination: the greater +//! minimum event count wins, and the lesser network id breaks ties. rumors +//! documents the rule under `Peer`, "Bootstrapping without consensus". The +//! losing side abandons its universe by bootstrap-joining the winner's +//! through a fresh link to the peer that beat it, and the process repeats +//! until one universe remains. +//! +//! The manager publishes its current [`Rumors`] handle on a watch channel. +//! A migration replaces the handle entirely; consumers must re-subscribe, +//! and everything the old universe carried is gone. +//! +//! TODO: re-inject local state into the new universe after a migration +//! (policy pending with the sush team). + +use std::cmp::Ordering; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::io; +use std::net::{SocketAddr, SocketAddrV6}; +use std::time::Duration; + +use borsh::{BorshDeserialize, BorshSerialize}; +use futures::StreamExt as _; +use rumors::{Error, Network, Peer, Rumors, Ticks}; +use slog::{Logger, debug, info, o, warn}; +use sprockets_tls::keys::SprocketsConfig; +use tokio::sync::watch; +use tokio::task::{AbortHandle, JoinSet}; +use tokio::time::{MissedTickBehavior, interval, timeout}; +use tokio::{select, spawn}; +use tokio_util::sync::CancellationToken; + +use rumors::link::routed::Endpoint; + +use crate::link::{CorpusSource, SprocketsDial, SprocketsLink, Transport}; + +/// Manager timing. The defaults suit a rack; tests shrink them. +#[derive(Clone, Debug)] +pub struct GossipConfig { + /// How often absent links are re-established. + pub reconnect: Duration, + /// Ceiling on establishing one link, and on each data-stream dial + /// inside a live one. + pub connect_timeout: Duration, + /// Ceiling on one bootstrap join. + pub join_timeout: Duration, +} + +impl Default for GossipConfig { + fn default() -> Self { + Self { + reconnect: Duration::from_secs(5), + connect_timeout: Duration::from_secs(30), + join_timeout: Duration::from_secs(60), + } + } +} + +/// A single-peer universe that never changes. The standalone server uses +/// this, as does a sled that cannot gossip. The receiver outlives its +/// sender. +pub fn isolated(seed: Rumors) -> watch::Receiver> { + let (_tx, rx) = watch::channel(seed); + rx +} + +/// Whether the peer's universe dominates ours, by rumors' documented rule. +fn remote_dominates( + local_events: &Ticks, + remote_events: &Ticks, + local_network: Network, + remote_network: Network, +) -> bool { + match remote_events.cmp(local_events) { + Ordering::Greater => true, + Ordering::Less => false, + // Any total order serves, as long as both sides use the same one. + Ordering::Equal => remote_network < local_network, + } +} + +/// Bind a sprockets transport on `listen_addr` and run a gossip manager +/// over it until `shutdown`. Returns the address the listener bound and +/// the channel following the current universe. A caller that cannot bind +/// may fall back to [`isolated`]. +#[allow(clippy::too_many_arguments)] +pub async fn spawn_gossip( + log: &Logger, + config: GossipConfig, + sprockets: SprocketsConfig, + corpus: CorpusSource, + listen_addr: SocketAddrV6, + peers: watch::Receiver>, + seed: Rumors, + shutdown: CancellationToken, +) -> io::Result<(SocketAddrV6, watch::Receiver>)> +where + T: BorshDeserialize + BorshSerialize + Send + Sync + 'static, +{ + let transport = Transport::new( + log, + sprockets, + corpus, + listen_addr, + config.connect_timeout, + shutdown.clone(), + ) + .await?; + let bound = transport.bound(); + let universe = spawn_gossip_manager(log, config, transport, peers, seed, shutdown); + Ok((bound, universe)) +} + +/// Run a gossip manager until `shutdown`. Establishes and serves links for +/// the addresses on `peers`, drives gossip on every link, and resolves +/// universe collisions. The returned channel follows the current universe, +/// starting at `seed`. +pub fn spawn_gossip_manager( + log: &Logger, + config: GossipConfig, + transport: Transport, + peers: watch::Receiver>, + seed: Rumors, + shutdown: CancellationToken, +) -> watch::Receiver> +where + T: BorshDeserialize + BorshSerialize + Send + Sync + 'static, +{ + let (publish, subscribe) = watch::channel(seed.clone()); + let manager = Manager { + log: log.new(o!("component" => "gossip manager")), + config, + endpoint: transport.endpoint(), + transport, + peers, + rumors: seed, + publish, + drivers: JoinSet::new(), + live: HashMap::new(), + dials: JoinSet::new(), + dialing: HashMap::new(), + joins: HashSet::new(), + shutdown, + }; + spawn(manager.run()); + subscribe +} + +/// A link establishment that finished, and the peer it was aimed at. +type Established = (SocketAddr, Result); + +/// Why a link's driver stopped. +enum Stopped { + /// Its session found a universe that dominates ours; we must join it. + Dominated, + /// The link failed, or was poisoned by a session. + Failed, +} + +struct Manager { + log: Logger, + config: GossipConfig, + endpoint: Endpoint, + transport: Transport, + peers: watch::Receiver>, + rumors: Rumors, + publish: watch::Sender>, + drivers: JoinSet<(SocketAddr, Stopped)>, + live: HashMap, + dials: JoinSet, + dialing: HashMap, + /// Peers whose universes beat ours, to be joined through. + joins: HashSet, + shutdown: CancellationToken, +} + +impl Manager +where + T: BorshDeserialize + BorshSerialize + Send + Sync + 'static, +{ + async fn run(mut self) { + let mut tick = interval(self.config.reconnect); + tick.set_missed_tick_behavior(MissedTickBehavior::Delay); + loop { + select! { + _ = self.shutdown.cancelled() => break, + _ = tick.tick() => self.link_absent(), + Ok(()) = self.peers.changed() => { + self.prune(); + self.link_absent(); + } + Some((peer, link)) = self.transport.accept() => { + self.on_link(peer, link).await; + } + Some(done) = self.dials.join_next() => { + if let Ok((peer, result)) = done { + self.dialing.remove(&peer); + match result { + Ok(link) => self.on_link(peer, link).await, + Err(err) => { + debug!(self.log, "link failed"; "peer" => %peer, "error" => err); + } + } + } + } + Some(done) = self.drivers.join_next() => { + if let Ok((peer, stopped)) = done { + self.live.remove(&peer); + if matches!(stopped, Stopped::Dominated) { + self.joins.insert(peer); + } + } + } + } + } + } + + /// The addresses the dial tiebreak makes ours to establish, so that a + /// pair of peers ends up with one link rather than two. + fn wanted(&self) -> BTreeSet { + let ours = *self.endpoint.local_addr(); + self.peers + .borrow() + .iter() + .map(|addr| SocketAddr::V6(*addr)) + .filter(|addr| *addr < ours) + .collect() + } + + /// Establish a link to every wanted peer without one already. + fn link_absent(&mut self) { + for peer in self.wanted() { + if self.live.contains_key(&peer) || self.dialing.contains_key(&peer) { + continue; + } + let deadline = self.config.connect_timeout; + let endpoint = self.endpoint.clone(); + let handle = self.dials.spawn(async move { + let result = match timeout(deadline, endpoint.link(peer)).await { + Ok(Ok(link)) => Ok(link), + Ok(Err(err)) => Err(err.to_string()), + Err(_) => Err("link establishment timed out".to_string()), + }; + (peer, result) + }); + self.dialing.insert(peer, handle); + } + } + + /// Tear down links and dials to peers no longer in the peer set, and + /// forgive any debt owed to them. + fn prune(&mut self) { + let want: BTreeSet<_> = self + .peers + .borrow() + .iter() + .map(|addr| SocketAddr::V6(*addr)) + .collect(); + let cull = |peer: &SocketAddr, handle: &mut AbortHandle| { + if want.contains(peer) { + return true; + } + handle.abort(); + false + }; + self.live.retain(cull); + self.dialing.retain(cull); + self.joins.retain(|peer| want.contains(peer)); + } + + /// Gossip on a fresh link, or use it to join a universe that beat ours. + async fn on_link(&mut self, peer: SocketAddr, link: SprocketsLink) { + if self.joins.contains(&peer) { + self.migrate(peer, link).await; + } else { + self.drive(peer, link); + } + } + + /// Spawn a session driver owning `link`: push our changes, and serve + /// whatever the peer initiates, until the link fails. + fn drive(&mut self, peer: SocketAddr, link: SprocketsLink) { + let rumors = self.rumors.clone(); + let log = self.log.clone(); + let handle = self + .drivers + .spawn(async move { (peer, sessions(&rumors, link, &log).await) }); + if let Some(stale) = self.live.insert(peer, handle) { + stale.abort(); + } + } + + /// Abandon our universe for the one `peer` belongs to, joining over the + /// fresh link to it. Every driver stops first: none may gossip across + /// the swap. On failure our universe is intact and the debt stands, so + /// the next link retries; either way all links are rebuilt, since the + /// old ones belong to the universe we are leaving. + async fn migrate(&mut self, peer: SocketAddr, mut link: SprocketsLink) { + self.drivers.abort_all(); + self.live.clear(); + info!( + self.log, "joining the universe that beat ours"; + "peer" => %peer, "ours" => %self.rumors.network(), + ); + match timeout(self.config.join_timeout, Peer::bootstrap().join(&mut link)).await { + Ok(Ok(Some(joined))) => { + self.rumors = joined.into_rumors(); + let _ = self.publish.send(self.rumors.clone()); + self.joins.clear(); + info!(self.log, "migrated"; "network" => %self.rumors.network()); + } + Ok(Ok(None)) => warn!(self.log, "mutual bootstrap, retrying"), + Ok(Err(err)) => warn!(self.log, "join failed"; "error" => %err), + Err(_) => warn!(self.log, "join timed out"), + } + drop(link); + self.link_absent(); + } +} + +/// Drive sessions on one link until it fails, reporting why. +async fn sessions(rumors: &Rumors, mut link: SprocketsLink, log: &Logger) -> Stopped +where + T: BorshDeserialize + BorshSerialize + Send + Sync + 'static, +{ + let ours = rumors.network(); + let mut driver = rumors.gossip_when(rumors.changes(), &mut link); + while let Some(session) = driver.next().await { + match session { + Ok(_) => {} + Err(Error::NetworkMismatch { + remote_network, + remote_min_events, + local_min_events, + }) => { + let dominated = + remote_dominates(&local_min_events, &remote_min_events, ours, remote_network); + debug!( + log, "universe mismatch"; + "ours" => %ours, "theirs" => %remote_network, + "our_events" => %local_min_events, + "their_events" => %remote_min_events, + "we_lose" => dominated, + ); + return if dominated { + Stopped::Dominated + } else { + Stopped::Failed + }; + } + Err(err) => { + debug!(log, "session failed"; "error" => %err); + return Stopped::Failed; + } + } + } + Stopped::Failed +} diff --git a/server/src/history.rs b/server/src/history.rs index eead89f..8a4e08b 100644 --- a/server/src/history.rs +++ b/server/src/history.rs @@ -104,7 +104,7 @@ impl JobHistory { if let Some(queued) = queued { ineligible.extend(queued.keys().cloned()); } - ineligible.extend(running.iter().map(|((id, _), _)| id.clone())); + ineligible.extend(running.iter().map(|((id, _), _)| *id)); // Cache causal jobs. let causal = self diff --git a/server/src/lib.rs b/server/src/lib.rs index 8a1bd98..1df6919 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -7,11 +7,16 @@ #[macro_use] extern crate function_name; +#[cfg(all(feature = "embedded", feature = "test-support"))] +compile_error!("`test-support` must not be enabled for an embedded server"); + pub mod error; pub mod executor; +pub mod gossip; pub mod history; pub mod io; pub mod job; +pub mod link; pub mod manager; pub mod messages; pub mod mux; @@ -22,6 +27,7 @@ pub mod server; pub mod state; pub use error::JobError; -pub use manager::JobManager; +pub use manager::{JobManager, read_root_certs}; pub use proxy::ProxyServer; pub use server::ApiServer; +pub use state::seed_gossip; diff --git a/server/src/link.rs b/server/src/link.rs new file mode 100644 index 0000000..88c555e --- /dev/null +++ b/server/src/link.rs @@ -0,0 +1,574 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Sprockets as a rumors [`routed`] transport. +//! +//! [`rumors::link::routed`] adapts an accept/connect transport to the link +//! contract, mapping every link stream to its own connection so that flow +//! control and half-close are the transport's own. This module supplies the +//! sprockets end of that adapter: a dialer, a listener, and the connection +//! type they exchange. Peers are authenticated and attested by sprockets, +//! which is what rumors asks of a transport it trusts. +//! +//! The price of the shape is a full sprockets handshake per stream, which +//! RFD 620 accepts. A per-peer stash of warm connections keeps that price +//! off the critical path during bursts: dials pop a pre-established +//! connection when one is ready, and refills run in the background only +//! while a peer stays active. Expiry is enforced at dial time, so the +//! last connection of a burst may linger well past its TTL. +//! +//! A stashed connection can die while it waits, most plausibly when the +//! peer restarts or its router evicts a connection that never spoke. A +//! stream built on such a connection fails its whole session and the +//! link is re-established, the same recovery as any other transport +//! failure. A silently dead path is worse: a warm connection skips the +//! dial timeout, leaving the session timeout as the backstop, which is +//! the routed contract's own answer for established connections that +//! go dark. + +use std::collections::{HashMap, VecDeque}; +use std::io; +use std::net::{SocketAddr, SocketAddrV6}; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; +use std::time::{Duration, Instant}; + +use camino::Utf8PathBuf; +use rumors::link::routed::{Config, Dial, Endpoint, Incoming, Listen, RoutedLink}; +use slog::{Logger, info, o, warn}; +use sprockets_tls::keys::SprocketsConfig; +use sprockets_tls::{Client, Server}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _, ReadBuf}; +use tokio::net::TcpStream; +use tokio::runtime::Handle; +use tokio::sync::mpsc; +use tokio::time::{sleep, timeout}; +use tokio::{select, spawn}; +use tokio_util::sync::CancellationToken; + +/// The [`Link`](rumors::link::Link) this transport builds. +pub type SprocketsLink = RoutedLink; + +/// Where the attestation corpus comes from, consulted per handshake. +pub type CorpusSource = Arc Vec + Send + Sync>; + +/// Completed handshakes the listener holds while the router catches up. +const HANDSHAKE_QUEUE_DEPTH: usize = 64; + +/// How long to wait after a failed accept before accepting again. +const ACCEPT_RETRY: Duration = Duration::from_millis(100); + +/// One sprockets connection, carrying one link stream. +/// +/// Dropping this must leave the peer at end-of-stream, because that is how +/// the session closes a data stream. A dropped TLS connection sends a bare +/// FIN, which rustls reports to the peer as an unexpected end of file, so +/// the drop hands the connection to a task that shuts it down properly. +pub struct SprocketsConn { + stream: Option>, +} + +impl SprocketsConn { + fn new(stream: sprockets_tls::Stream) -> Self { + SprocketsConn { + stream: Some(stream), + } + } + + fn stream(&mut self) -> Pin<&mut sprockets_tls::Stream> { + Pin::new(self.stream.as_mut().expect("stream present until drop")) + } +} + +impl AsyncRead for SprocketsConn { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.stream().poll_read(cx, buf) + } +} + +impl AsyncWrite for SprocketsConn { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.stream().poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.stream().poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.stream().poll_shutdown(cx) + } +} + +impl Drop for SprocketsConn { + fn drop(&mut self) { + let Some(mut stream) = self.stream.take() else { + return; + }; + // Outside a runtime there is no one left to read the closing alert + // either, so let the connection drop abruptly. + if let Ok(handle) = Handle::try_current() { + handle.spawn(async move { + let _ = stream.shutdown().await; + }); + } + } +} + +/// Inbound connections the link router holds mid-header. Every peer +/// may park a stashed connection on our router, on top of the usual +/// dial bursts, so this needs room for a whole rack of both. +const PENDING_HEADERS: usize = 128; + +/// Per-peer warm connections. +const STASH_DEPTH: usize = 1; + +/// How long a stashed connection stays usable. +const STASH_TTL: Duration = Duration::from_secs(60); + +/// How recently a peer must have been dialed for refills to continue. +/// Refills stop outside this window, so an idle rack makes no +/// handshakes at all. +const STASH_ACTIVITY: Duration = Duration::from_secs(30); + +/// Warm connections held per peer, ready to become streams. A dial +/// pays a full attested handshake, with the RoT in the loop over IPCC, +/// so a burst of streams should run against connections established +/// off the critical path. +struct Stash { + peers: HashMap>, +} + +struct PeerStash { + conns: VecDeque<(C, Instant)>, + last_dial: Instant, + refill: Refill, +} + +/// Whether a refill task is in flight for a peer. +enum Refill { + Idle, + Pending, +} + +impl Stash { + fn new() -> Self { + Stash { + peers: HashMap::new(), + } + } + + /// Take the freshest warm connection for `addr`, noting the dial. + fn pop(&mut self, addr: &SocketAddrV6, now: Instant) -> Option { + let peer = self.peers.get_mut(addr)?; + peer.last_dial = now; + while let Some((conn, born)) = peer.conns.pop_back() { + if now.duration_since(born) < STASH_TTL { + return Some(conn); + } + } + None + } + + /// Note a fresh dial to `addr`, opening its activity window. + fn note_dial(&mut self, addr: SocketAddrV6, now: Instant) { + let peer = self.peers.entry(addr).or_insert_with(|| PeerStash { + conns: VecDeque::new(), + last_dial: now, + refill: Refill::Idle, + }); + peer.last_dial = now; + } + + /// Whether a refill for `addr` should start now. Claims the refill + /// slot when it returns true; the caller must report back with + /// [`refilled`](Self::refilled). + fn want_refill(&mut self, addr: &SocketAddrV6, now: Instant) -> bool { + let Some(peer) = self.peers.get_mut(addr) else { + return false; + }; + if matches!(peer.refill, Refill::Pending) + || peer.conns.len() >= STASH_DEPTH + || now.duration_since(peer.last_dial) > STASH_ACTIVITY + { + return false; + } + peer.refill = Refill::Pending; + true + } + + /// Report a claimed refill's result. + fn refilled(&mut self, addr: &SocketAddrV6, conn: Option, now: Instant) { + if let Some(peer) = self.peers.get_mut(addr) { + assert!(matches!(peer.refill, Refill::Pending)); + peer.refill = Refill::Idle; + if let Some(conn) = conn + && peer.conns.len() < STASH_DEPTH + { + peer.conns.push_back((conn, now)); + } + } + } + + /// Drop expired connections and forget idle peers. + fn sweep(&mut self, now: Instant) { + for peer in self.peers.values_mut() { + peer.conns + .retain(|(_, born)| now.duration_since(*born) < STASH_TTL); + } + self.peers.retain(|_, peer| { + matches!(peer.refill, Refill::Pending) + || !peer.conns.is_empty() + || now.duration_since(peer.last_dial) <= STASH_ACTIVITY + }); + } +} + +/// Dials one attested sprockets connection per link stream, keeping a +/// warm stash per active peer so bursts skip the handshake latency. +#[derive(Clone)] +pub struct SprocketsDial { + log: Logger, + config: SprocketsConfig, + corpus: CorpusSource, + timeout: Duration, + stash: Arc>>, +} + +impl SprocketsDial { + /// Dial with `config`, appraising peers against the corpus of the + /// moment, and giving up on any one dial after `timeout`. + pub fn new( + log: &Logger, + config: SprocketsConfig, + corpus: CorpusSource, + timeout: Duration, + ) -> Self { + SprocketsDial { + log: log.new(o!("component" => "sprockets dial")), + config, + corpus, + timeout, + stash: Arc::new(Mutex::new(Stash::new())), + } + } + + /// One full sprockets handshake to `addr`. + async fn connect(&self, addr: SocketAddrV6) -> io::Result { + let started = Instant::now(); + let config = self.config.clone(); + let corpus = self.corpus.clone(); + let log = self.log.clone(); + // Sprockets connects are not cancel safe, so the handshake runs in + // its own task and this future only awaits the result. The corpus + // closure runs in the task too, so a panic there surfaces as a + // failed dial instead of unwinding the caller. + let dial = spawn(async move { + Client::connect(config, addr, corpus(), log) + .await + .map_err(io::Error::other) + }); + match timeout(self.timeout, dial).await { + Ok(joined) => { + let conn = SprocketsConn::new(joined.map_err(io::Error::other)??); + info!( + self.log, "handshake complete"; + "peer" => %addr, + "elapsed_ms" => started.elapsed().as_millis(), + ); + Ok(conn) + } + Err(_) => Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("dialing {addr} timed out"), + )), + } + } + + /// Start a background refill for `addr` if its stash wants one. + fn refill(&self, addr: SocketAddrV6) { + if !self + .stash + .lock() + .unwrap() + .want_refill(&addr, Instant::now()) + { + return; + } + let this = self.clone(); + spawn(async move { + let conn = match this.connect(addr).await { + Ok(conn) => Some(conn), + Err(err) => { + warn!(this.log, "stash refill failed"; "peer" => %addr, "error" => %err); + None + } + }; + this.stash + .lock() + .unwrap() + .refilled(&addr, conn, Instant::now()); + }); + } +} + +impl Dial for SprocketsDial { + type Addr = SocketAddr; + type Conn = SprocketsConn; + + async fn dial(&self, addr: &SocketAddr) -> io::Result { + let SocketAddr::V6(addr) = *addr else { + return Err(io::Error::other(format!("sush gossip needs IPv6: {addr}"))); + }; + let warm = { + let mut stash = self.stash.lock().unwrap(); + let now = Instant::now(); + stash.sweep(now); + stash.pop(&addr, now) + }; + let conn = if let Some(conn) = warm { + conn + } else { + let conn = self.connect(addr).await?; + self.stash.lock().unwrap().note_dial(addr, Instant::now()); + conn + }; + self.refill(addr); + Ok(conn) + } +} + +/// This process's gossip transport: the sprockets endpoint peers link to, +/// and the links they establish toward us. +pub struct Transport { + endpoint: Endpoint, + incoming: Incoming, + bound: SocketAddrV6, +} + +impl Transport { + /// Listen on `listen_addr` and stand up the routing endpoint. Its + /// router runs until `shutdown`, or until the listener fails. + pub async fn new( + log: &Logger, + config: SprocketsConfig, + corpus: CorpusSource, + listen_addr: SocketAddrV6, + dial_timeout: Duration, + shutdown: CancellationToken, + ) -> io::Result { + let (listen, bound) = SprocketsListen::bind( + log, + config.clone(), + corpus.clone(), + listen_addr, + shutdown.clone(), + ) + .await?; + let dial = SprocketsDial::new(log, config, corpus, dial_timeout); + let (endpoint, incoming, router) = Endpoint::new( + listen, + SocketAddr::V6(bound), + dial, + Config { + pending_headers: PENDING_HEADERS, + ..Config::default() + }, + ) + .map_err(io::Error::other)?; + let log = log.new(o!("component" => "link router")); + spawn(async move { + select! { + _ = shutdown.cancelled() => {} + stopped = router => { + warn!(log, "router stopped, gossip is down"; "result" => ?stopped); + } + } + }); + Ok(Transport { + endpoint, + incoming, + bound, + }) + } + + /// The address the listener bound, which peers dial us at. + pub fn bound(&self) -> SocketAddrV6 { + self.bound + } + + /// A cheap handle for establishing links, free to move into tasks. + pub fn endpoint(&self) -> Endpoint { + self.endpoint.clone() + } + + /// Receive the next link a peer established toward us, with the name it + /// advertised, or `None` once the router has stopped. + pub async fn accept(&mut self) -> Option<(SocketAddr, SprocketsLink)> { + let (info, link) = self.incoming.accept().await?; + Some((info.peer, link)) + } +} + +/// Yields this endpoint's inbound sprockets connections. +/// +/// Accepting is two phases, and the second attests the peer, so this runs a +/// pump: one task accepts and hands each connection to its own handshake +/// task, and completed connections queue here. Handshakes therefore proceed +/// concurrently, and [`Listen::accept`] is a queue receive, which the +/// router may cancel freely. +pub struct SprocketsListen { + connections: mpsc::Receiver, +} + +impl SprocketsListen { + /// Listen on `listen_addr`, returning the listener and the address it + /// actually bound, which is the name to advertise to peers. + pub async fn bind( + log: &Logger, + config: SprocketsConfig, + corpus: CorpusSource, + listen_addr: SocketAddrV6, + shutdown: CancellationToken, + ) -> io::Result<(Self, SocketAddrV6)> { + let log = log.new(o!("component" => "sprockets listen")); + let server = Server::new(config, listen_addr, log.clone()) + .await + .map_err(io::Error::other)?; + let bound = match server.listen_addr()? { + SocketAddr::V6(addr) => addr, + SocketAddr::V4(addr) => { + return Err(io::Error::other(format!("listening on IPv4 {addr}"))); + } + }; + let (tx, connections) = mpsc::channel(HANDSHAKE_QUEUE_DEPTH); + spawn(pump(server, corpus, tx, log, shutdown)); + Ok((SprocketsListen { connections }, bound)) + } +} + +impl Listen for SprocketsListen { + type Conn = SprocketsConn; + + async fn accept(&mut self) -> io::Result { + self.connections + .recv() + .await + .ok_or_else(|| io::Error::new(io::ErrorKind::BrokenPipe, "sprockets listener stopped")) + } +} + +/// Accept connections and attest them until `shutdown`. +async fn pump( + server: Server, + corpus: CorpusSource, + connections: mpsc::Sender, + log: Logger, + shutdown: CancellationToken, +) { + loop { + select! { + _ = shutdown.cancelled() => break, + accepted = server.accept((corpus)()) => match accepted { + Ok(acceptor) => { + let connections = connections.clone(); + let log = log.clone(); + spawn(async move { + match acceptor.handshake().await { + // A closed queue means the endpoint is gone. + Ok((stream, _)) => { + let _ = connections.send(SprocketsConn::new(stream)).await; + } + Err(err) => { + warn!(log, "handshake failed"; "error" => %err); + } + } + }); + } + Err(err) => { + warn!(log, "accept failed"; "error" => %err); + sleep(ACCEPT_RETRY).await; + } + }, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + const ADDR: SocketAddrV6 = SocketAddrV6::new(std::net::Ipv6Addr::LOCALHOST, 1, 0, 0); + + #[test] + fn stash_lifecycle() { + let mut stash: Stash = Stash::new(); + let t0 = Instant::now(); + + // Unknown peers neither pop nor refill. + assert_eq!(stash.pop(&ADDR, t0), None); + assert!(!stash.want_refill(&ADDR, t0)); + + // A fresh dial opens the activity window and claims one refill. + stash.note_dial(ADDR, t0); + assert!(stash.want_refill(&ADDR, t0)); + assert!(!stash.want_refill(&ADDR, t0), "refill slot is claimed"); + stash.refilled(&ADDR, Some(7), t0); + + // The stash is full, so no further refill; the warm connection + // pops and then wants refilling again. + assert!(!stash.want_refill(&ADDR, t0)); + assert_eq!(stash.pop(&ADDR, t0), Some(7)); + assert_eq!(stash.pop(&ADDR, t0), None); + assert!(stash.want_refill(&ADDR, t0)); + stash.refilled(&ADDR, None, t0); + + // Expired connections do not pop. + assert!(stash.want_refill(&ADDR, t0)); + stash.refilled(&ADDR, Some(8), t0); + assert_eq!(stash.pop(&ADDR, t0 + STASH_TTL), None); + + // The expired pop counted as a dial, so the window is open just + // inside its boundary and closed just outside; only then does a + // sweep forget the peer. + let edge = t0 + STASH_TTL + STASH_ACTIVITY; + assert!(stash.want_refill(&ADDR, edge)); + stash.refilled(&ADDR, None, edge); + let late = edge + Duration::from_secs(1); + assert!(!stash.want_refill(&ADDR, late)); + stash.sweep(late); + assert!(stash.peers.is_empty()); + } + + /// A sweep must keep a peer whose refill is in flight, or the + /// refill would report to a forgotten peer and drop its connection. + #[test] + fn sweep_keeps_pending_refills() { + let mut stash: Stash = Stash::new(); + let t0 = Instant::now(); + stash.note_dial(ADDR, t0); + assert!(stash.want_refill(&ADDR, t0)); + + let late = t0 + STASH_TTL + STASH_ACTIVITY + Duration::from_secs(1); + stash.sweep(late); + assert!(!stash.peers.is_empty(), "pending refill retains the peer"); + stash.refilled(&ADDR, Some(9), late); + assert_eq!(stash.pop(&ADDR, late), Some(9)); + + // Reporting a refill for a forgotten peer drops the connection. + stash.sweep(late + STASH_TTL + STASH_ACTIVITY + Duration::from_secs(1)); + assert!(stash.peers.is_empty()); + stash.refilled(&ADDR, Some(10), late); + assert_eq!(stash.pop(&ADDR, late), None); + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 8df2b3c..2cc8cd5 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -6,21 +6,26 @@ use std::net::SocketAddr; use std::path::PathBuf; +use std::sync::Arc; use clap::Parser; use dropshot::{ConfigDropshot, ConfigLogging, ConfigLoggingLevel, HandlerTaskMode, ServerBuilder}; -use rumors::Peer; use sled_hardware_types::BaseboardId; use tokio::signal::unix::{SignalKind, signal}; +use tokio::sync::watch; use tokio::{select, spawn}; use tokio_util::sync::CancellationToken; use x509_cert::Certificate; use x509_cert::der::DecodePem as _; use sush_api::sush_api_mod::api_description; +use sush_common::targets::Cubbies; use sush_server::executor::PathIsolation; +use sush_server::gossip::isolated; use sush_server::manager::JobManager; +use sush_server::output::JobOutputDir; use sush_server::server::ApiServer; +use sush_server::{read_root_certs, seed_gossip}; const DEFAULT_ADDRESS: &str = "0.0.0.0:44444"; const ROOT_CERTS: &[&[u8]] = &[include_bytes!("../certs/sandbox.pem")]; @@ -89,19 +94,21 @@ async fn main() -> Result<(), String> { }; // TODO: get/seed Rumors network - let gossip = Peer::seed().into_rumors(); + let gossip = isolated(seed_gossip()); #[cfg(feature = "test-support")] - let roots = overridable_root_certs(&override_root_certs)?; + let roots = overridable_root_certs(&override_root_certs).await?; #[cfg(not(feature = "test-support"))] let roots = builtin_root_certs()?; let shutdown = listen_for_shutdown()?; - let mut mgr = JobManager::new( + let (_cubbies, cubbies) = watch::channel(Cubbies::new()); + let mut mgr = JobManager::with_root_certs( log.clone(), path_isolation, - directory, + JobOutputDir::fixed(directory), baseboard, + cubbies, gossip, &roots, shutdown.clone(), @@ -112,12 +119,13 @@ async fn main() -> Result<(), String> { let api = api_description::() .map_err(|error| format!("failed to get API description: {error}"))?; - let server = ServerBuilder::new(api, mgr, log) + let server = ServerBuilder::new(api, Arc::new(mgr), log) .config(ConfigDropshot { bind_address: address, default_request_body_max_bytes: REQUEST_MAX_BODY_BYTES, default_handler_task_mode: HandlerTaskMode::Detached, log_headers: vec![], + compression: Default::default(), }) .start() .map_err(|error| format!("failed to start server: {error}"))?; @@ -141,17 +149,15 @@ fn builtin_root_certs() -> Result, String> { } #[cfg_attr(not(feature = "test-support"), expect(dead_code))] -fn overridable_root_certs(override_root_certs: &[PathBuf]) -> Result, String> { +async fn overridable_root_certs( + override_root_certs: &[PathBuf], +) -> Result, String> { if override_root_certs.is_empty() { builtin_root_certs() } else { - override_root_certs - .iter() - .map(|path| { - Certificate::from_pem(&std::fs::read(path).map_err(|e| e.to_string())?) - .map_err(|e| e.to_string()) - }) - .collect() + read_root_certs(override_root_certs) + .await + .map_err(|e| e.to_string()) } } diff --git a/server/src/manager.rs b/server/src/manager.rs index 928ec98..3fc0ad4 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -6,21 +6,23 @@ //! state machine. Does not manage jobs directly. use std::num::NonZeroUsize; -use std::path::PathBuf; +use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; -use chrono::{DateTime, Utc}; +use chrono::Utc; use http_range_header::SyntacticallyCorrectRange as Range; use lru::LruCache; use sled_hardware_types::BaseboardId; use slog::{Logger, debug, info, o, warn}; +use tokio::fs::read; use tokio::sync::{Mutex, mpsc, watch}; use tokio::task::JoinHandle; use tokio::time::timeout; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; use x509_cert::Certificate; +use x509_cert::der::DecodePem as _; use sush_api::{JobStartParams, JobStopParams, JobWait}; use sush_common::authn::{ @@ -30,11 +32,12 @@ use sush_common::authn::{ use sush_common::jobs::JobOutputStream; use sush_common::jobs::{Access, JobId, JobStatusMap, Session, SessionId, SignedJob}; use sush_common::keys::{KeyError, KeyId, SshPublicKey}; +use sush_common::targets::Cubbies; use crate::error::JobError; use crate::executor::PathIsolation; use crate::job::SocketSender; -use crate::messages::v0::{CertRequest, JobRequest, Request, SessionRequest}; +use crate::messages::v0::{CertRequest, IdentityRequest, JobRequest, Request, SessionRequest}; use crate::output::{JobOutputDir, JobOutputFileStream}; use crate::state::{GossipNetwork, MAX_CERTS, State, StateManager}; @@ -47,13 +50,6 @@ const MAX_CACHED_IDENTITIES: NonZeroUsize = NonZeroUsize::new(1_000).unwrap(); /// for the whole nonce TTL to lock a user out. const MAX_OUTSTANDING_NONCES: NonZeroUsize = NonZeroUsize::new(10_000).unwrap(); -/// Maximum number of revoked SSH keys remembered for login refusal. -/// Any authenticated key may revoke, so a flood of junk revocations -/// can evict a real one. Entries never expire, so unlike nonces the -/// eviction is permanent. This is sized so the flood takes thousands -/// of authenticated, attributed, logged requests. -const MAX_REVOKED_KEYS: NonZeroUsize = NonZeroUsize::new(10_000).unwrap(); - /// Maximum amount of time we're willing to wait for job start or stop. const WAIT_TIMEOUT: Duration = Duration::from_secs(600); @@ -85,7 +81,6 @@ pub struct JobManager { log: Logger, nonces: Arc>>, identities: Arc>>, - revoked_keys: Arc>>>, output_dir: JobOutputDir, own_baseboard: BaseboardId, state: watch::Receiver, // from the state manager @@ -94,16 +89,44 @@ pub struct JobManager { } impl JobManager { + /// Trust the root certificates in `roots`, one PEM-encoded certificate + /// per file. + #[allow(clippy::too_many_arguments)] pub async fn new( log: Logger, path_isolation: PathIsolation, - output_dir: PathBuf, + output_dir: JobOutputDir, own_baseboard: BaseboardId, - rumors: GossipNetwork, + cubbies: watch::Receiver, + universe: watch::Receiver, + roots: &[impl AsRef], + shutdown: CancellationToken, + ) -> Result { + let roots = read_root_certs(roots).await?; + Self::with_root_certs( + log, + path_isolation, + output_dir, + own_baseboard, + cubbies, + universe, + &roots, + shutdown, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + pub async fn with_root_certs( + log: Logger, + path_isolation: PathIsolation, + output_dir: JobOutputDir, + own_baseboard: BaseboardId, + cubbies: watch::Receiver, + universe: watch::Receiver, roots: &[Certificate], shutdown: CancellationToken, ) -> Result { - let output_dir = JobOutputDir::new(output_dir); let (tx_req, rx_req) = mpsc::channel(16); let requests = ReceiverStream::new(rx_req); let (rx_state, join_state) = StateManager::run( @@ -112,15 +135,15 @@ impl JobManager { output_dir.clone(), own_baseboard.clone(), requests, - rumors, + cubbies, + universe, roots, shutdown, - ); + )?; Ok(Self { log: log.new(o!("component" => "job manager")), nonces: Arc::new(Mutex::new(LruCache::new(MAX_OUTSTANDING_NONCES))), identities: Arc::new(Mutex::new(LruCache::new(MAX_CACHED_IDENTITIES))), - revoked_keys: Arc::new(Mutex::new(LruCache::new(MAX_REVOKED_KEYS))), own_baseboard, output_dir, state: rx_state, @@ -158,6 +181,17 @@ impl JobManager { .map_err(|_| JobError::ChannelClosed) } + async fn identity_request( + &self, + actor: KeyId, + request: IdentityRequest, + ) -> Result<(), JobError> { + self.tx_req + .send(Request::identity(actor, request)) + .await + .map_err(|_| JobError::ChannelClosed) + } + // Certificate management. pub async fn cert_import( @@ -222,7 +256,7 @@ impl JobManager { macro_rules! unauthorized { ($error:expr) => {{ warn!(self.log, "authentication failed"; "error" => %$error); - let nonce = Nonce::generate(); + let nonce = Nonce::random(); self.nonces.lock().await.put(nonce.clone(), Instant::now()); return Err(JobError::unauthorized(nonce)); }}; @@ -252,6 +286,26 @@ impl JobManager { Authn::Bound(bound) => { let mut identities = self.identities.lock().await; let cache_key = (bound.key_id.clone(), bound.nonce.clone()); + // A login elsewhere on the rack reaches us as a + // registered identity. Adopt it on first use. + if !identities.contains(&cache_key) { + let Some(registered) = + self.state.borrow().registered_identity(&cache_key).cloned() + else { + unauthorized!("unknown identity"); + }; + debug!(self.log, "adopting registered identity"; "key_id" => %bound.key_id); + identities.put( + cache_key.clone(), + CachedIdentity { + identity: registered.identity, + verifier: registered.verifier, + window: SeqWindow::default(), + authenticated: Instant::now(), + last_used: Instant::now(), + }, + ); + } let Some(cached) = identities.get_mut(&cache_key) else { unauthorized!("unknown identity"); }; @@ -280,7 +334,7 @@ impl JobManager { } // Refuse revoked keys. - if self.revoked_keys.lock().await.get(&key_id).is_some() { + if self.state.borrow().is_key_revoked(&key_id) { unauthorized!("key is revoked"); } @@ -300,13 +354,14 @@ impl JobManager { unauthorized!("invalid key ID"); } let response = credentials.clone().into_challenge_response(); - let verified = try_authn!(response.verify_with_ssh_public_key(&public_key)); - let identity = try_authn!(Identity::new(public_key.to_owned(), verified, now)); + let verified = try_authn!(response.clone().verify_with_ssh_public_key(&public_key)); + let identity = try_authn!(Identity::new(public_key.clone(), verified, now)); - // Authenticated! Cache the identity and its request verifier. + // Authenticated! Cache the identity and its request verifier, + // and gossip the evidence. debug!(self.log, "authenticated credentials for identity"; "key_id" => %key_id); self.identities.lock().await.put( - (key_id.to_owned(), nonce), + (key_id.clone(), nonce), CachedIdentity { identity: identity.clone(), verifier: credentials.epk, @@ -315,7 +370,10 @@ impl JobManager { last_used: Instant::now(), }, ); - Ok(identity) + let login = IdentityRequest::Login(public_key, response); + self.identity_request(key_id, login) + .await + .map(|()| identity) } pub async fn identities(&self, _authn: &Identity) -> Result, JobError> { @@ -329,15 +387,27 @@ impl JobManager { .collect()) } - pub async fn iam_revoke(&self, _authn: &Identity, key_id: KeyId) -> Result<(), JobError> { + pub async fn iam_revoke( + &self, + authn: &Identity, + key_id: KeyId, + wait: bool, + ) -> Result<(), JobError> { let now = Utc::now(); let mut identities = self.identities.lock().await; for (_, cached) in identities.iter_mut().filter(|((id, _), _)| *id == key_id) { cached.identity.time_revoked = Some(now); } drop(identities); - self.revoked_keys.lock().await.put(key_id.clone(), now); - info!(self.log, "revoked identity"; "key_id" => %key_id); + info!(self.log, "revoking identity"; "key_id" => %key_id, "actor" => %authn.key_id); + self.identity_request( + authn.key_id.clone(), + IdentityRequest::Revoke(key_id.clone(), now), + ) + .await?; + if wait { + self.wait_for(self.wait_for_key_revocation(key_id)).await?; + } Ok(()) } @@ -355,11 +425,15 @@ impl JobManager { move |state| state.is_cert_revoked(&key_id) } + fn wait_for_key_revocation(&self, key_id: KeyId) -> impl FnMut(&State) -> bool { + move |state| state.is_key_revoked(&key_id) + } + fn wait_for_session(&self, session_id: SessionId) -> impl FnMut(&State) -> bool { move |state| { state .session() - .is_some_and(|s| *s.session_id() == session_id) + .is_some_and(|s| s.session_id() == session_id) } } @@ -386,9 +460,8 @@ impl JobManager { } /// Wait until the local state manager has recorded *any* status for - /// this job on this baseboard, including `Queued`. Used only to make - /// the `Queued` state observable in tests. - #[cfg(feature = "test-support")] + /// this job on this baseboard, including `Queued`. This is what makes the + /// `Queued` state observable. pub async fn wait_for_job_status(&self, job_id: &JobId) -> Result<(), JobError> { let job_id = job_id.to_owned(); let baseboard = self.own_baseboard().to_owned(); @@ -420,11 +493,10 @@ impl JobManager { session_id: SessionId, wait: bool, ) -> Result<(), JobError> { - self.session_request(authn, SessionRequest::Start(session_id.clone())) + self.session_request(authn, SessionRequest::Start(session_id)) .await?; if wait { - self.wait_for(self.wait_for_session(session_id.clone())) - .await?; + self.wait_for(self.wait_for_session(session_id)).await?; } Ok(()) } @@ -606,4 +678,15 @@ impl JobManager { } } +/// Read one PEM-encoded certificate from each of `paths`. +pub async fn read_root_certs(paths: &[impl AsRef]) -> Result, JobError> { + let mut roots = Vec::with_capacity(paths.len()); + for path in paths { + let path = path.as_ref(); + let pem = read(path).await.map_err(JobError::file_io_for(path))?; + roots.push(Certificate::from_pem(&pem)?); + } + Ok(roots) +} + // See tests in `tests/src/manager_tests.rs` diff --git a/server/src/messages.rs b/server/src/messages.rs index 7563bf3..9ed896a 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -15,13 +15,14 @@ use uuid::Uuid; use x509_cert::Certificate; use sush_api::JobStartParams; +use sush_common::authn::SignedLogin; use sush_common::borsh::{ borsh_de_baseboard_id, borsh_de_cert, borsh_de_datetime, borsh_ser_baseboard_id, borsh_ser_cert, borsh_ser_datetime, }; use sush_common::jobs::JobOutputState; use sush_common::jobs::{Access, JobId, ProcessError, SessionId, SignedJob}; -use sush_common::keys::KeyId; +use sush_common::keys::{KeyId, SshPublicKey}; #[derive(BorshDeserialize, BorshSerialize, Copy, Clone, Debug, Eq, PartialEq)] pub struct RequestId(pub Uuid); @@ -43,6 +44,7 @@ pub mod v0 { use super::*; #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] + #[allow(clippy::large_enum_variant)] pub enum Message { Request(Request), Event( @@ -84,6 +86,7 @@ pub mod v0 { Cert(Box>), Session(Box>), Job(Box>), + Identity(Box>), } impl Request { @@ -99,6 +102,10 @@ pub mod v0 { Self::Job(Box::new(Attributed { actor, request })) } + pub fn identity(actor: KeyId, request: IdentityRequest) -> Self { + Self::Identity(Box::new(Attributed { actor, request })) + } + pub fn kind(&self) -> &'static str { match self { Self::Cert(r) => match r.request { @@ -116,6 +123,10 @@ pub mod v0 { JobRequest::Start(..) => "job start", JobRequest::Stop(_) => "job stop", }, + Self::Identity(r) => match r.request { + IdentityRequest::Login(..) => "identity login", + IdentityRequest::Revoke(..) => "identity revoke", + }, } } } @@ -148,11 +159,29 @@ pub mod v0 { } #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] + #[allow(clippy::large_enum_variant)] pub enum JobRequest { Start(SignedJob, JobStartParams), Stop(JobId), } + /// Identity gossip carries evidence, not assertions: every sled + /// verifies a login's challenge-response signature itself, so a + /// registered identity never depends on another sled's honesty. + #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] + #[allow(clippy::large_enum_variant)] + pub enum IdentityRequest { + Login(SshPublicKey, SignedLogin), + Revoke( + KeyId, + #[borsh( + serialize_with = "borsh_ser_datetime", + deserialize_with = "borsh_de_datetime" + )] + DateTime, + ), + } + #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] pub enum Event { Job(JobEvent), @@ -208,84 +237,159 @@ pub mod v0 { #[cfg(test)] mod wire_format { + use std::env; + use std::fs::{read, write}; + use super::v0::*; use super::*; + use std::str::FromStr as _; + use sush_common::authn::Nonce; - /// Serialize a message and compare against a pinned hex snapshot. + /// Serialize a message and compare against the snapshot in + /// `tests/output/`, or rewrite it under `EXPECTORATE=overwrite`. /// /// If this test fails, STOP! You have changed the gossip wire format! /// Because borsh enum tags are positional, reordering or inserting /// variants (or editing any struct these messages contain) silently /// changes how *old* bytes decode. Either revert the schema change - /// or introduce a new [`VersionedMessage`] variant and re-pin these + /// or introduce a new [`VersionedMessage`] variant and re-pin the /// snapshots. #[track_caller] - fn assert_wire_format(message: VersionedMessage, expected: &[u8]) { + fn assert_wire_format(name: &str, message: VersionedMessage) { let bytes = borsh::to_vec(&message).unwrap(); - assert_eq!(bytes, expected, "gossip wire format changed: {bytes:02x?}"); + let path = format!("tests/output/{name}.bin"); + if env::var("EXPECTORATE").as_deref() == Ok("overwrite") { + write(&path, &bytes).unwrap(); + } else { + let expected = read(&path).expect("missing snapshot"); + assert_eq!(bytes, expected, "gossip wire format changed: {bytes:02x?}"); + } let decoded: VersionedMessage = borsh::from_slice(&bytes).unwrap(); assert_eq!(decoded, message, "wire format should round-trip"); } + fn sid(name: &str) -> SessionId { + name.parse().unwrap() + } + #[test] fn session_start_request() { let msg: VersionedMessage = Message::Request(Request::session( - KeyId::from("zoo-zero".to_string()), - SessionRequest::Start(SessionId::from("abandon-ability")), + KeyId::from_str("zoo-zero").unwrap(), + SessionRequest::Start(sid("abandon-ability")), )) .into(); - assert_wire_format( - msg, - b"\x00\x00\x01\x08\x00\x00\x00zoo-zero\x00\x0f\x00\x00\x00abandon-ability", - ); + assert_wire_format("session-start-request", msg); } #[test] fn session_stop_request() { let msg: VersionedMessage = Message::Request(Request::session( - KeyId::from("zoo-zero".to_string()), - SessionRequest::Stop(SessionId::from("abandon-ability")), + KeyId::from_str("zoo-zero").unwrap(), + SessionRequest::Stop(sid("abandon-ability")), )) .into(); - assert_wire_format( - msg, - b"\x00\x00\x01\x08\x00\x00\x00zoo-zero\x01\x0f\x00\x00\x00abandon-ability", - ); + assert_wire_format("session-stop-request", msg); } #[test] fn session_allow_attach_request() { let msg: VersionedMessage = Message::Request(Request::session( - KeyId::from("zoo-zero".to_string()), + KeyId::from_str("zoo-zero").unwrap(), SessionRequest::AllowAttach( - SessionId::from("abandon-ability"), - KeyId::from("able-about".to_string()), + sid("abandon-ability"), + KeyId::from_str("able-about").unwrap(), Access::ReadWrite, ), )) .into(); - assert_wire_format( - msg, - b"\x00\x00\x01\x08\x00\x00\x00zoo-zero\x03\x0f\x00\x00\x00abandon-ability\ - \x0a\x00\x00\x00able-about\x01", - ); + assert_wire_format("session-allow-attach-request", msg); } #[test] fn session_deny_attach_request() { let msg: VersionedMessage = Message::Request(Request::session( - KeyId::from("zoo-zero".to_string()), + KeyId::from_str("zoo-zero").unwrap(), SessionRequest::DenyAttach( - SessionId::from("abandon-ability"), - KeyId::from("able-about".to_string()), + sid("abandon-ability"), + KeyId::from_str("able-about").unwrap(), ), )) .into(); - assert_wire_format( - msg, - b"\x00\x00\x01\x08\x00\x00\x00zoo-zero\x04\x0f\x00\x00\x00abandon-ability\ - \x0a\x00\x00\x00able-about", + assert_wire_format("session-deny-attach-request", msg); + } + + #[test] + fn job_start_request() { + use sush_common::jobs::JobStartRequest; + use sush_common::keys::{EncodedSignature, Signed}; + + let request = JobStartRequest::new( + "abandon-abandon-abandon-abandon-abandon-abandon-abandon-ability" + .parse() + .unwrap(), + "echo hello", + false, + "14,16".parse().unwrap(), + ); + let signed = Signed::new( + request, + KeyId::from_str("zoo-zero").unwrap(), + EncodedSignature { + r: "abandon".parse().unwrap(), + s: "zoo".parse().unwrap(), + flags: 0, + counter: 0, + }, + ); + let msg: VersionedMessage = Message::Request(Request::job( + KeyId::from_str("zoo-zero").unwrap(), + JobRequest::Start(signed, JobStartParams::default()), + )) + .into(); + assert_wire_format("job-start-request", msg); + } + + #[test] + fn identity_login_request() { + use sush_common::authn::ChallengeResponse; + use sush_common::keys::{EncodedSignature, Signed, SshPublicKey}; + + // Craft deterministic evidence: nonces, then the ed25519 + // basepoint as the verifier. + let mut evidence = Vec::new(); + for nonce in [ + Nonce::from_str("abandon").unwrap(), + Nonce::from_str("ability").unwrap(), + ] { + evidence.extend(&nonce.to_be_bytes()); + } + evidence.push(0x58); + evidence.extend([0x66; 31]); + let response = ChallengeResponse::try_from_slice(&evidence).unwrap(); + + let openssh = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ13gEiGB test@sush"; + let mut key = Vec::new(); + key.extend((openssh.len() as u32).to_le_bytes()); + key.extend(openssh.as_bytes()); + let public_key = SshPublicKey::try_from_slice(&key).unwrap(); + + let signed = Signed::new( + response, + KeyId::from_str("zoo-zero").unwrap(), + EncodedSignature { + r: "abandon".parse().unwrap(), + s: "zoo".parse().unwrap(), + flags: 0, + counter: 0, + }, ); + let msg: VersionedMessage = Message::Request(Request::identity( + KeyId::from_str("zoo-zero").unwrap(), + IdentityRequest::Login(public_key, signed), + )) + .into(); + assert_wire_format("identity-login-request", msg); } // TODO: snapshot more messages diff --git a/server/src/output.rs b/server/src/output.rs index ab0c354..6fbf1e5 100644 --- a/server/src/output.rs +++ b/server/src/output.rs @@ -4,29 +4,32 @@ //! Abstract job output directories. //! -//! We use a trivial hierarchy: `{base}/jobs/{job_id}`. +//! We use a trivial hierarchy: `{base}/jobs/{job_id}`. The base may move over +//! the life of a server; see [`OutputDirs`]. use std::path::{Path, PathBuf}; use std::pin::Pin; use std::task::{Context, Poll}; -use bytes::Bytes; use bytesize::ByteSize; -use futures::{Stream, TryStreamExt as _}; +use futures::Stream; use http_range_header::{EndPosition, StartPosition, SyntacticallyCorrectRange as Range}; use tokio::fs::{File, metadata}; use tokio::io; use tokio::io::{AsyncReadExt as _, AsyncSeekExt as _, SeekFrom, Take}; -use tokio::sync::{Semaphore, SemaphorePermit}; +use tokio::sync::{Semaphore, SemaphorePermit, watch}; use tokio_util::io::ReaderStream; -use sush_common::jobs::{ExecutionError, JobId, JobOutputStream}; +use sush_common::jobs::{ExecutionError, JobId, JobLimits, JobOutputStream}; use crate::JobError; /// Output files or ranges larger than this will not be served all at once. const OUTPUT_THRESHOLD: u64 = ByteSize::mb(128).as_u64(); +/// Maximum number of superseded output directories we keep around to read from. +const MAX_PREVIOUS_DIRS: usize = 4; + /// Limit concurrent output requests. const MAX_OUTPUT_PERMITS: usize = 100; static OUTPUT_PERMITS: Semaphore = Semaphore::const_new(MAX_OUTPUT_PERMITS); @@ -53,17 +56,6 @@ impl JobOutputFileStream { pub fn length(&self) -> u64 { self.length } - - #[cfg(feature = "test-support")] - pub async fn into_bytes(self) -> Vec { - self.stream - .try_collect::>() - .await - .unwrap() - .into_iter() - .flatten() - .collect() - } } impl Stream for JobOutputFileStream { @@ -74,37 +66,136 @@ impl Stream for JobOutputFileStream { } } +/// Where job output is written, and how much of it we will record. +/// +/// New jobs are written under [`current`](Self::current). `previous` holds +/// bases this server wrote to earlier in its life, retained so that output +/// recorded before a move stays readable. `max_fsize` is the ceiling on +/// per-job output size while `current` is in force; it belongs here because +/// it is a property of the filesystem being written to, not of the job. +/// +/// The sled-agent embedding needs all of these to change at runtime: it starts +/// out writing to a ramdisk, where a large limit would be charged to global +/// zone memory, and moves to an encrypted dataset with a larger limit once one +/// is mounted (which is to say, once trust quorum has been established). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OutputDirs { + current: PathBuf, + previous: Vec, + max_fsize: u64, +} + +impl OutputDirs { + pub fn new(current: impl Into, max_fsize: u64) -> Self { + Self { + current: current.into(), + previous: Vec::new(), + max_fsize, + } + } + + /// The base that new jobs are written under. + pub fn current(&self) -> &Path { + &self.current + } + + /// The ceiling on per-job output size under [`current`](Self::current). + pub fn max_fsize(&self) -> u64 { + self.max_fsize + } + + /// Move to a new base, retaining this one to read old output from. + pub fn moved_to(&self, current: impl Into, max_fsize: u64) -> Self { + let current = current.into(); + let mut previous = Vec::with_capacity(self.previous.len() + 1); + if current != self.current { + previous.push(self.current.clone()); + } + previous.extend(self.previous.iter().filter(|dir| **dir != current).cloned()); + previous.truncate(MAX_PREVIOUS_DIRS); + Self { + current, + previous, + max_fsize, + } + } + + /// Every base we may have written output to, most recent first. + pub fn bases(&self) -> impl Iterator { + std::iter::once(self.current.as_path()).chain(self.previous.iter().map(PathBuf::as_path)) + } + + pub fn job_output_dir(&self, job_id: &JobId) -> PathBuf { + job_output_dir_in(&self.current, job_id) + } + + pub fn job_output_path(&self, job_id: &JobId, stream: JobOutputStream) -> PathBuf { + self.job_output_dir(job_id).join(stream.as_str()) + } +} + +fn job_output_dir_in(base: &Path, job_id: &JobId) -> PathBuf { + base.join("jobs").join(job_id.to_string()) +} + #[derive(Clone, Debug)] -pub struct JobOutputDir(PathBuf); +pub struct JobOutputDir(watch::Receiver); impl JobOutputDir { - pub fn new(output_dir: PathBuf) -> Self { - Self(output_dir) + /// Follow a set of output directories that may move at runtime. + pub fn new(dirs: watch::Receiver) -> Self { + Self(dirs) + } + + /// A base that never moves, with the default output size limit: for the + /// standalone server, which has one directory for its whole life. + pub fn fixed(dir: impl Into) -> Self { + // A receiver keeps serving the last value after its sender is dropped, + // and we only ever read, so there is nothing to keep alive here. + let (_tx, rx) = watch::channel(OutputDirs::new(dir, JobLimits::default().max_fsize)); + Self(rx) } - pub fn root(&self) -> &Path { - &self.0 + /// Snapshot the directories in force right now. Callers that derive more + /// than one path for a job must do it through a single snapshot, so that + /// a move can't leave a job's files split across two bases. + pub fn current(&self) -> OutputDirs { + self.0.borrow().clone() + } + + pub fn root(&self) -> PathBuf { + self.0.borrow().current.clone() } pub fn job_output_dir(&self, job_id: &JobId) -> PathBuf { - self.0.join("jobs").join(job_id.to_string()) + self.0.borrow().job_output_dir(job_id) } pub fn job_output_path(&self, job_id: &JobId, stream: JobOutputStream) -> PathBuf { - self.job_output_dir(job_id).join(stream.as_str()) + self.0.borrow().job_output_path(job_id, stream) } - async fn job_output_len( - &self, - job_id: &JobId, - stream: JobOutputStream, - ) -> Result { - let path = self.job_output_path(job_id, stream); - match metadata(&path).await.map(|m| m.len()) { + /// The path recording this job's `stream`, which may be under a base we + /// have since moved away from. Falls back to the current base, so that + /// there is always a path to name in an error. + async fn find_job_output(&self, job_id: &JobId, stream: JobOutputStream) -> PathBuf { + let dirs = self.current(); + for base in dirs.bases() { + let path = job_output_dir_in(base, job_id).join(stream.as_str()); + match metadata(&path).await { + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + _ => return path, + } + } + dirs.job_output_path(job_id, stream) + } + + async fn job_output_len(job_id: &JobId, path: &Path) -> Result { + match metadata(path).await.map(|m| m.len()) { Ok(len) => Ok(len), Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(0), Err(err) => Err(ExecutionError::io( - job_id.clone(), + *job_id, format!("getting length of {}", path.display()), err, )), @@ -122,8 +213,8 @@ impl JobOutputDir { }; let job_id = job_id.to_owned(); - let len = self.job_output_len(&job_id, stream).await?; - let path = self.job_output_path(&job_id, stream); + let path = self.find_job_output(&job_id, stream).await; + let len = Self::job_output_len(&job_id, &path).await?; let io_error = JobError::file_io_for(&path); let mut file = match File::open(&path).await { Ok(file) => file, diff --git a/server/src/proxy.rs b/server/src/proxy.rs index 1c47043..10a21cd 100644 --- a/server/src/proxy.rs +++ b/server/src/proxy.rs @@ -4,40 +4,114 @@ //! Proxy server for the Oxide Support Shell API. //! -//! Loosely adapted from the Wicketd/Nexus proxy. +//! Terminates client connections and routes each request to a sled. +//! A request that names a target goes to the first sled the target +//! resolves to. Anything else goes to a sticky default, because +//! identities are cached on the sled that authenticated them. +//! Requests are forwarded untouched: bound request signatures cover +//! the exact request line, so the proxy may never rewrite one. +use std::collections::BTreeMap; +use std::convert::Infallible; use std::io; use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; -use slog::{Logger, error, info, o}; -use tokio::io::copy_bidirectional; +use http::StatusCode; +use http_body_util::{Either, Full}; +use hyper::body::{Bytes, Incoming}; +use hyper::client::conn::http1 as client_conn; +use hyper::server::conn::http1 as server_conn; +use hyper::service::service_fn; +use hyper::upgrade::OnUpgrade; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use percent_encoding::percent_decode_str; +use rustls::ServerConfig; +use rustls::version::TLS13; +use sled_hardware_types::BaseboardId; +use slog::{Logger, debug, error, info, o, warn}; +use sprockets_tls::keys::{CertResolver, ResolveSetting}; +use tokio::io::{AsyncRead, AsyncWrite, copy_bidirectional}; use tokio::net::{TcpListener, TcpStream}; -use tokio::{select, spawn}; +use tokio::sync::watch; +use tokio::{select, spawn, try_join}; +use tokio_rustls::TlsAcceptor; use tokio_util::sync::CancellationToken; +use sush_common::targets::{Cubbies, SledId, Target}; + +/// The sush servers the proxy may route to. +#[derive(Clone, Debug, Default)] +pub struct Targets { + /// Server addresses by baseboard. + pub sleds: BTreeMap, + /// Baseboards by cubby number, as much of it as is known. + pub cubbies: Cubbies, +} + +impl Targets { + /// The address of the first sled `target` resolves to. For + /// routing, a list means the first of these that is reachable. + fn resolve(&self, target: &Target) -> Option { + let Target::Sleds(sleds) = target else { + return None; + }; + sleds + .iter() + .filter_map(|sled| match sled { + SledId::Baseboard(baseboard) => Some(baseboard), + SledId::Cubby(cubby) => self.cubbies.get(cubby), + }) + .find_map(|baseboard| self.sleds.get(baseboard)) + .copied() + } +} + +/// Backend responses pass through. Proxy errors carry their own body. +type ProxyBody = Either>; + +/// The proxy's TLS identity is the sled's platform identity. +pub fn platform_tls(log: &Logger, resolve: ResolveSetting) -> Result { + let log = log.new(o!("component" => "proxy-cert-resolver")); + let resolver = Arc::new(CertResolver::new(log, resolve)); + Ok( + ServerConfig::builder_with_provider(Arc::new(sprockets_tls::crypto_provider())) + .with_protocol_versions(&[&TLS13])? + .with_no_client_auth() + .with_cert_resolver(resolver), + ) +} + pub struct ProxyServer { local_addr: SocketAddr, shutdown: CancellationToken, } impl ProxyServer { - /// Start listening at `local_addr`. Does not connect to `remote_addr` - /// until a client connection is accepted. + /// Start listening at `local_addr`, routing to `targets`. pub async fn start( log: &Logger, local_addr: SocketAddr, - remote_addr: SocketAddr, + tls: Option, + targets: watch::Receiver, shutdown: CancellationToken, ) -> io::Result { let listener = TcpListener::bind(local_addr).await?; let local_addr = listener.local_addr()?; + let acceptor = tls.map(|config| TlsAcceptor::from(Arc::new(config))); + let router = Arc::new(Router { + targets, + default: Mutex::new(None), + }); spawn(listen( log.new(o!("component" => "proxy")), listener, - remote_addr, + acceptor, + router, shutdown.clone(), )); - info!(log, "started proxy server"); + info!(log, "started proxy server"; "local_addr" => local_addr); Ok(Self { local_addr, shutdown, @@ -53,10 +127,80 @@ impl ProxyServer { } } +/// Pick a sled for each request. +struct Router { + targets: watch::Receiver, + default: Mutex>, +} + +impl Router { + fn route(&self, request: &Request) -> Result>> { + let targets = self.targets.borrow(); + match named_target(request) { + Some(Ok(target)) => targets.resolve(&target).ok_or_else(|| { + Box::new(error_response( + StatusCode::BAD_GATEWAY, + format!("no route to target `{target}`"), + )) + }), + Some(Err(bad)) => Err(Box::new(error_response( + StatusCode::BAD_REQUEST, + format!("unable to parse target `{bad}`"), + ))), + None => { + let mut default = self.default.lock().unwrap(); + if let Some(baseboard) = default.as_ref() + && let Some(addr) = targets.sleds.get(baseboard) + { + return Ok(*addr); + } + match targets.sleds.iter().next() { + Some((baseboard, addr)) => { + *default = Some(baseboard.clone()); + Ok(*addr) + } + None => Err(Box::new(error_response( + StatusCode::SERVICE_UNAVAILABLE, + "no sleds known to the proxy", + ))), + } + } + } + } +} + +/// The target a request names, if any. A `*` path segment names no +/// particular sled, so the `via` query parameter may still route. +fn named_target(request: &Request) -> Option> { + let uri = request.uri(); + let segments: Vec<&str> = uri.path().trim_start_matches('/').split('/').collect(); + let named = match segments.as_slice() { + ["jobs", _, "output" | "attach", target, ..] if *target != "*" => Some(*target), + _ => uri.query().and_then(|query| { + query.split('&').find_map(|param| { + param + .split_once('=') + .filter(|(key, _)| *key == "via") + .map(|(_, value)| value) + }) + }), + }?; + let decoded = match percent_decode_str(named).decode_utf8() { + Ok(decoded) => decoded, + Err(_) => return Some(Err(named.to_string())), + }; + match decoded.parse() { + Ok(Target::All) => None, + Ok(target) => Some(Ok(target)), + Err(_) => Some(Err(decoded.into_owned())), + } +} + async fn listen( log: Logger, listener: TcpListener, - remote_addr: SocketAddr, + acceptor: Option, + router: Arc, shutdown: CancellationToken, ) { loop { @@ -64,7 +208,19 @@ async fn listen( result = listener.accept() => { match result { Ok((client, client_addr)) => { - spawn(accept(log.clone(), client, client_addr, remote_addr)); + let log = log.new(o!("client_addr" => client_addr)); + let router = router.clone(); + match acceptor.clone() { + Some(acceptor) => spawn(async move { + match acceptor.accept(client).await { + Ok(client) => serve(log, client, router).await, + Err(err) => { + debug!(log, "TLS handshake failed"; "error" => %err); + } + } + }), + None => spawn(serve(log, client, router)), + }; } Err(err) => { error!(log, "accept failed"; "error" => %err); @@ -80,41 +236,155 @@ async fn listen( } } -async fn accept( - log: Logger, - mut client: TcpStream, - client_addr: SocketAddr, - server_addr: SocketAddr, -) { - let mut server = match TcpStream::connect(server_addr).await { - Ok(stream) => { - info!( - log, "connection established"; - "server_addr" => %server_addr, - "client_addr" => %client_addr, - ); - stream - } - Err(err) => { - error!( - log, "failed to connect to server"; - "server_addr" => %server_addr, - "error" => %err, - ); - return; +/// Serve one client connection, forwarding each request to the sled +/// the router picks for it. +async fn serve(log: Logger, client: S, router: Arc) +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let service = service_fn(|request| { + let log = log.clone(); + let router = router.clone(); + async move { + Ok::<_, Infallible>(match router.route(&request) { + Ok(addr) => forward(log, addr, request).await, + Err(response) => *response, + }) } - }; + }); + if let Err(err) = server_conn::Builder::new() + .serve_connection(TokioIo::new(client), service) + .with_upgrades() + .await + { + debug!(log, "client connection ended"; "error" => %err); + } +} - match copy_bidirectional(&mut client, &mut server).await { - Ok((client_to_server, server_to_client)) => { - info!( - log, "connection closed"; - "bytes_sent_to_server" => client_to_server, - "bytes_sent_to_client" => server_to_client, - ); +/// Forward one request to the sled at `addr` and relay the response. +async fn forward(log: Logger, addr: SocketAddr, request: Request) -> Response { + match try_forward(log, addr, request).await { + Ok(response) => response.map(Either::Left), + Err(err) => error_response( + StatusCode::BAD_GATEWAY, + format!("unable to reach sled at `{addr}`: {err}"), + ), + } +} + +/// A `101 Switching Protocols` response upgrades both connections and +/// bridges them. +async fn try_forward( + log: Logger, + addr: SocketAddr, + mut request: Request, +) -> Result, Box> { + let stream = TcpStream::connect(addr).await?; + let (mut sender, conn) = client_conn::handshake(TokioIo::new(stream)).await?; + spawn({ + let log = log.clone(); + async move { + if let Err(err) = conn.with_upgrades().await { + debug!(log, "sled connection ended"; "error" => %err); + } } - Err(err) => { - error!(log, "error relaying to server"; "error" => %err); + }); + + debug!( + log, "forwarding"; + "method" => %request.method(), "target" => %request.uri(), "addr" => addr, + ); + let client_upgrade = hyper::upgrade::on(&mut request); + let mut response = sender.send_request(request).await?; + if response.status() == StatusCode::SWITCHING_PROTOCOLS { + let sled_upgrade = hyper::upgrade::on(&mut response); + spawn(bridge(log, client_upgrade, sled_upgrade)); + } + Ok(response) +} + +/// Splice the two upgraded connections together. +async fn bridge(log: Logger, client: OnUpgrade, sled: OnUpgrade) { + match try_join!(client, sled) { + Ok((client, sled)) => { + if let Err(err) = + copy_bidirectional(&mut TokioIo::new(client), &mut TokioIo::new(sled)).await + { + debug!(log, "bridged connection ended"; "error" => %err); + } } + Err(err) => warn!(log, "upgrade failed"; "error" => %err), + } +} + +fn error_response(status: StatusCode, message: impl Into) -> Response { + Response::builder() + .status(status) + .body(Either::Right(Full::new(message.into()))) + .expect("error response should build") +} + +#[cfg(test)] +mod test { + use super::*; + + fn request(path_and_query: &str) -> Request<()> { + Request::builder().uri(path_and_query).body(()).unwrap() + } + + fn target(s: &str) -> Target { + s.parse().unwrap() + } + + #[test] + fn named_targets() { + assert_eq!( + named_target(&request("/jobs/some-job/output/test%20part:0000/stdout")), + Some(Ok(target("test part:0000"))), + ); + assert_eq!( + named_target(&request("/jobs/some-job/attach/test%20part:0000")), + Some(Ok(target("test part:0000"))), + ); + assert_eq!( + named_target(&request("/iam?via=test%20part:0000")), + Some(Ok(target("test part:0000"))), + ); + assert_eq!( + named_target(&request("/jobs/some-job/output/*/stdout?via=14,16")), + Some(Ok(target("14,16"))), + ); + assert_eq!(named_target(&request("/jobs/some-job/attach/*")), None); + assert_eq!(named_target(&request("/iam?via=*")), None); + assert_eq!( + named_target(&request("/jobs/some-job/output/nonsense/stdout")), + Some(Err(String::from("nonsense"))), + ); + assert_eq!(named_target(&request("/jobs/some-job/status")), None); + assert_eq!(named_target(&request("/iam")), None); + assert_eq!(named_target(&request("/sessions?wait=true")), None); + } + + #[test] + fn resolution() { + let brm31: BaseboardId = "913-0000019:BRM42220031".parse().unwrap(); + let brm40: BaseboardId = "913-0000019:BRM42220040".parse().unwrap(); + let addr31: SocketAddr = "[::1]:31000".parse().unwrap(); + let addr40: SocketAddr = "[::1]:40000".parse().unwrap(); + let targets = Targets { + sleds: BTreeMap::from([(brm31.clone(), addr31), (brm40.clone(), addr40)]), + cubbies: Cubbies::from([(14, brm31), (16, brm40)]), + }; + + assert_eq!( + targets.resolve(&target("913-0000019:BRM42220031")), + Some(addr31), + ); + assert_eq!(targets.resolve(&target("16")), Some(addr40)); + // The first routable element wins. + assert_eq!(targets.resolve(&target("3,16,14")), Some(addr40)); + assert_eq!(targets.resolve(&target("3")), None); + assert_eq!(targets.resolve(&Target::All), None); + assert_eq!(Targets::default().resolve(&target("14")), None); } } diff --git a/server/src/pty.rs b/server/src/pty.rs index 7139795..e2f618d 100644 --- a/server/src/pty.rs +++ b/server/src/pty.rs @@ -16,7 +16,7 @@ use std::pin::Pin; use std::task::{Context, Poll, ready}; use rustix::fs::{Mode, OFlags, fcntl_setfl, open}; -use rustix::io::{read, write}; +use rustix::io::{FdFlags, fcntl_setfd, read, write}; use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt}; use rustix::termios::{tcgetwinsize, tcsetwinsize}; @@ -26,9 +26,12 @@ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use sush_common::interactive::WindowSize; /// Open and configure a Unix pseudoterminal. We return a reader/control -/// handle, a write handle, the slave fd, and its path. +/// handle, a write handle, the slave fd, and its path. The master gets +/// CLOEXEC after the fact, because illumos cannot set it atomically at +/// open. pub fn open_pty() -> io::Result<(PtyReader, PtyWriter, OwnedFd, PathBuf)> { - let pty = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC)?; + let pty = openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY)?; + fcntl_setfd(&pty, FdFlags::CLOEXEC)?; grantpt(&pty)?; unlockpt(&pty)?; fcntl_setfl(&pty, OFlags::NONBLOCK)?; diff --git a/server/src/server.rs b/server/src/server.rs index b646498..76bbb56 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -4,6 +4,8 @@ //! API server for the Oxide Support Shell. +use std::sync::Arc; + use dropshot::{ Body, CONTENT_TYPE_OCTET_STREAM, ClientErrorStatusCode, Header, HttpError, HttpResponseOk, HttpResponseUpdatedNoContent, Path as PathParams, Query as QueryParams, RequestContext, @@ -23,8 +25,8 @@ use x509_cert::der::DecodePem as _; use sush_api::{ AccessParam, Authorization, AuthorizedRangeRequest, JobAttachParams, JobHistoryParams, - JobIdParam, JobOutputParams, JobStartParams, JobStopParams, KeyIdParam, SessionAndJobIds, - SessionAndKeyIds, SessionIdParam, SushApi, WaitParam, + JobIdParam, JobOutputParams, JobStartParams, JobStopParams, KeyIdParam, RoutingParam, + SessionAndJobIds, SessionAndKeyIds, SessionIdParam, SushApi, WaitParam, }; use sush_common::authn::Identity; use sush_common::jobs::{JsonJobStatusMap, Session, SignedJob, job_status_to_json_map}; @@ -48,7 +50,7 @@ fn request_line(request: &dropshot::RequestInfo) -> (&str, &str) { } impl SushApi for ApiServer { - type Context = JobManager; + type Context = Arc; // Certificate management. @@ -109,6 +111,7 @@ impl SushApi for ApiServer { async fn iam( ctx: RequestContext, headers: Header, + _query: QueryParams, body: TypedBody>, ) -> Result, HttpError> { let mgr = ctx.context(); @@ -136,6 +139,7 @@ impl SushApi for ApiServer { ctx: RequestContext, headers: Header, params: PathParams, + query: QueryParams, ) -> Result { let mgr = ctx.context(); let Authorization { authorization } = headers.into_inner(); @@ -143,7 +147,8 @@ impl SushApi for ApiServer { .iam(authorization, None, request_line(&ctx.request)) .await?; let KeyIdParam { key_id } = params.into_inner(); - mgr.iam_revoke(&authn, key_id).await?; + let WaitParam { wait } = query.into_inner(); + mgr.iam_revoke(&authn, key_id, wait).await?; Ok(HttpResponseUpdatedNoContent()) } @@ -178,7 +183,7 @@ impl SushApi for ApiServer { .await?; let WaitParam { wait } = query.into_inner(); let SessionIdParam { session_id } = params.into_inner(); - mgr.session_start(&authn, session_id.clone(), wait).await?; + mgr.session_start(&authn, session_id, wait).await?; Ok(HttpResponseUpdatedNoContent()) } @@ -308,6 +313,7 @@ impl SushApi for ApiServer { ctx: RequestContext, headers: Header, params: PathParams, + _query: QueryParams, ) -> Result, HttpError> { let mgr = ctx.context(); let headers = headers.into_inner(); @@ -348,6 +354,7 @@ impl SushApi for ApiServer { ctx: RequestContext, headers: Header, params: PathParams, + _query: QueryParams, upgrade: WebsocketUpgrade, ) -> WebsocketEndpointResult { let mgr = ctx.context(); @@ -403,13 +410,8 @@ impl SushApi for ApiServer { async fn target( ctx: RequestContext, - headers: Header, + _query: QueryParams, ) -> Result, HttpError> { - let mgr = ctx.context(); - let Authorization { authorization } = headers.into_inner(); - let _authn = mgr - .iam(authorization, None, request_line(&ctx.request)) - .await?; - Ok(HttpResponseOk(mgr.own_baseboard().to_owned())) + Ok(HttpResponseOk(ctx.context().own_baseboard().to_owned())) } } diff --git a/server/src/state.rs b/server/src/state.rs index 1c2631f..27da3ed 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use chrono::{DateTime, Utc}; use futures::{Stream, StreamExt}; use lru::LruCache; -use rumors::{Key, Rumors, Version}; +use rumors::{Key, Peer, Rumors, Version}; use sled_hardware_types::BaseboardId; use slog::{Logger, debug, error, info, o, warn}; use tokio::sync::watch; @@ -25,14 +25,17 @@ use x509_cert::Certificate; use x509_cert::der::Encode as _; use sush_api::JobStartParams; +use sush_common::authn::{Identity, Nonce, RequestVerifier, SignedLogin}; use sush_common::jobs::{Access, JobId, JobStatus, JobStatusMap, Session, SessionId, SignedJob}; -use sush_common::keys::{KeyError, KeyId, Signature}; +use sush_common::keys::{KeyError, KeyId, Signature, SshPublicKey}; +use sush_common::targets::Cubbies; use crate::executor::{Executor, PathIsolation}; use crate::history::JobHistory; use crate::job::SocketSender; use crate::messages::v0::{ - CertRequest, Error, Event, JobEvent, JobRequest, Message, Request, SessionRequest, + CertRequest, Error, Event, IdentityRequest, JobEvent, JobRequest, Message, Request, + SessionRequest, }; use crate::messages::{VersionedMessage, VersionedMessage::*}; use crate::output::JobOutputDir; @@ -58,6 +61,24 @@ pub const MAX_QUEUED_JOBS: usize = 1_000; /// Maximum number of revocations held for certificates not yet seen. pub const MAX_TOMBSTONES: NonZeroUsize = NonZeroUsize::new(100).unwrap(); +/// Maximum number of registered identities. Evicting a real login +/// only costs its holder a fresh key touch. +pub const MAX_REGISTERED_IDENTITIES: NonZeroUsize = NonZeroUsize::new(1_000).unwrap(); + +/// Maximum number of revoked SSH keys remembered for login refusal. +/// Any authenticated key may revoke, so a flood of junk revocations +/// can evict a real one. Entries never expire, so unlike nonces the +/// eviction is permanent. This is sized so the flood takes thousands +/// of authenticated, attributed, logged requests. +pub const MAX_REVOKED_KEYS: NonZeroUsize = NonZeroUsize::new(10_000).unwrap(); + +/// A rack-wide record of a verified login. +#[derive(Clone, Debug)] +pub struct RegisteredIdentity { + pub identity: Identity, + pub verifier: RequestVerifier, +} + #[derive(Clone, Debug)] pub enum SessionState { Inactive { @@ -152,7 +173,7 @@ struct SessionGuard<'a> { } impl<'a> SessionGuard<'a> { - pub fn session_id(&self) -> &SessionId { + pub fn session_id(&self) -> SessionId { self.inner.session_id() } @@ -161,7 +182,7 @@ impl<'a> SessionGuard<'a> { } pub fn skip_job(&mut self, job_id: &JobId) { - self.inner.skip_job(job_id.clone()) + self.inner.skip_job(*job_id) } pub fn next_queued_job(&mut self) -> Option<(SignedJob, JobStartParams)> { @@ -175,11 +196,13 @@ impl<'a> SessionGuard<'a> { history: &mut JobHistory, running: &RunningJobs, own_baseboard: &BaseboardId, + cubbies: &Cubbies, job: SignedJob, params: JobStartParams, actor: &KeyId, ) { - let job_id = job.job_id().clone(); + let job_id = *job.job_id(); + let targeted = job.payload().target().includes(own_baseboard, cubbies); if history.contains(&job_id) { // Note but otherwise ignore the duplicate job. info!(log, "already started job"; "job_id" => %job_id); @@ -189,20 +212,24 @@ impl<'a> SessionGuard<'a> { // We have no choice; drop the job on the floor. warn!(log, "too many jobs queued"; "job_id" => %job_id, "max" => MAX_QUEUED_JOBS); } else { - // Insert the job into our queue and record its new status. - self.queued_jobs.insert(job_id.clone(), (job, params)); - history.set_job_status( - &job_id, - own_baseboard, - JobStatus::Queued { - job_id: job_id.clone(), - time_queued: Utc::now(), - actor: actor.clone(), - }, - None, - Some(self.queued_jobs), - running, - ); + // Insert the job into our queue. Every job joins the + // queue to keep the causal chain whole, but only jobs + // targeting this sled record a local status. + self.queued_jobs.insert(job_id, (job, params)); + if targeted { + history.set_job_status( + &job_id, + own_baseboard, + JobStatus::Queued { + job_id, + time_queued: Utc::now(), + actor: actor.clone(), + }, + None, + Some(self.queued_jobs), + running, + ); + } } } @@ -221,7 +248,7 @@ impl<'a> SessionGuard<'a> { None, |old_status| match old_status { None | Some(JobStatus::Queued { .. }) => Some(JobStatus::Cancelled { - job_id: job_id.clone(), + job_id: *job_id, time_cancelled: Utc::now(), actor: actor.clone(), }), @@ -240,6 +267,7 @@ impl<'a> SessionGuard<'a> { pub fn execute_ready_jobs( &mut self, own_baseboard: &BaseboardId, + cubbies: &Cubbies, certs: &mut Certificates, history: &mut JobHistory, executor: &mut Executor, @@ -248,13 +276,16 @@ impl<'a> SessionGuard<'a> { while let Some((request, params)) = self.next_queued_job() { let (tx_attachment, rx_attachment) = watch::channel(None); let job_id = request.payload().job_id().to_owned(); - if history - .get_job_status(&job_id) - .map(|status| matches!(status.get(own_baseboard), Some(JobStatus::Queued { .. }))) - .unwrap_or(true) + if request.payload().target().includes(own_baseboard, cubbies) + && history + .get_job_status(&job_id) + .map(|status| { + matches!(status.get(own_baseboard), Some(JobStatus::Queued { .. })) + }) + .unwrap_or(true) { executor.job_start(certs, request.clone(), params, tx_attachment); - attachments.insert(job_id.clone(), rx_attachment); + attachments.insert(job_id, rx_attachment); } self.job_started(request); } @@ -281,19 +312,28 @@ pub struct State { /// Hard-coded root certificate key IDs. Self-signed certificates /// not in this set will be revoked with prejudice. roots: Box<[KeyId]>, + /// Baseboards by cubby number, as much of it as is known. + cubbies: Cubbies, + /// Verified logins, rack-wide. + identities: LruCache<(KeyId, Nonce), RegisteredIdentity>, + /// SSH keys refused at login. + revoked_keys: LruCache>, } impl State { - pub fn new(own_baseboard: BaseboardId, root_certs: &[Certificate]) -> Self { + /// Fails if any root certificate is malformed or does not validate. Roots + /// are supplied by whoever configures the server, so they are not + /// necessarily trustworthy just because they were handed to us. + pub fn new(own_baseboard: BaseboardId, root_certs: &[Certificate]) -> Result { let certs = root_certs .iter() .map(|cert| { - ( - KeyId::try_from(cert).expect("root certificate must be well-formed"), + Ok(( + KeyId::try_from(cert)?, CertState::Unknown(Box::new(cert.clone())), - ) + )) }) - .collect::>(); + .collect::, KeyError>>()?; let roots: Box<[KeyId]> = certs.keys().cloned().collect(); let mut new = Self { own_baseboard, @@ -304,12 +344,19 @@ impl State { tombstones: LruCache::new(MAX_TOMBSTONES), certs, roots: roots.clone(), + cubbies: Default::default(), + identities: LruCache::new(MAX_REGISTERED_IDENTITIES), + revoked_keys: LruCache::new(MAX_REVOKED_KEYS), }; new.validate_certs(&roots); for root in &roots { - assert!(new.certs[root].is_valid()); + if !new.certs[root].is_valid() { + return Err(KeyError::InvalidCert(format!( + "root certificate `{root}` does not validate" + ))); + } } - new + Ok(new) } pub fn get_attachment(&self, job_id: &JobId) -> Option { @@ -379,6 +426,15 @@ impl State { || self.tombstones.peek(key_id).is_some() } + pub fn registered_identity(&self, key: &(KeyId, Nonce)) -> Option<&RegisteredIdentity> { + self.identities.peek(key) + } + + pub fn is_key_revoked(&self, key_id: &KeyId) -> bool { + self.revoked_keys.peek(key_id).is_some() + } + + #[allow(clippy::result_large_err)] fn update( &mut self, log: &Logger, @@ -419,7 +475,7 @@ impl State { // Re-announcement of active session; absorb and ignore. Active { frontier, session, .. - } if session.session_id() == session_id => { + } if session.session_id() == *session_id => { *frontier |= incoming_version.clone(); info!(log, "duplicate session start"; "session_id" => %session_id); } @@ -439,10 +495,7 @@ impl State { self.session = Active { frontier: self.session.frontier() | incoming_version.clone(), started: incoming_version.clone(), - session: Box::new(Session::started( - session_id.clone(), - actor.clone(), - )), + session: Box::new(Session::started(*session_id, actor.clone())), queued_jobs: QueuedJobs::new(), attach_grants: BTreeMap::new(), } @@ -460,9 +513,9 @@ impl State { .. } if incoming_version.partial_cmp(frontier).is_none() => { let error = Error::ConcurrentSessions { - own_session: session.session_id().clone(), + own_session: session.session_id(), own_version: started.clone(), - incoming_session: session_id.clone(), + incoming_session: *session_id, incoming_version: incoming_version.clone(), }; self.session = Inactive { @@ -498,7 +551,7 @@ impl State { if let Active { frontier, session, .. } = &self.session - && session.session_id() == session_id + && session.session_id() == *session_id { info!( log, "session stopped"; @@ -515,7 +568,7 @@ impl State { attach_grants, .. } = &mut self.session - && session.session_id() == session_id + && session.session_id() == *session_id { if session.started_by() == Some(actor) { attach_grants.insert(key_id.clone(), *access); @@ -537,7 +590,7 @@ impl State { attach_grants, .. } = &mut self.session - && session.session_id() == session_id + && session.session_id() == *session_id { if session.started_by() == Some(actor) { attach_grants.remove(key_id); @@ -555,7 +608,7 @@ impl State { } (actor, SessionRequest::Skip(session_id, job_id)) => { if let Some(mut session) = self.session.active_session() - && session.session_id() == session_id + && session.session_id() == *session_id { info!( log, "job skipped"; @@ -571,6 +624,7 @@ impl State { ); session.execute_ready_jobs( &self.own_baseboard, + &self.cubbies, &mut self.certs, &mut self.history, executor, @@ -616,12 +670,14 @@ impl State { &mut self.history, &self.running, &self.own_baseboard, + &self.cubbies, signed.clone(), params.clone(), actor, ); session.execute_ready_jobs( &self.own_baseboard, + &self.cubbies, &mut self.certs, &mut self.history, executor, @@ -642,19 +698,59 @@ impl State { } } }, + Request::Identity(attributed) => match attributed.as_parts() { + (actor, IdentityRequest::Login(public_key, signed)) => { + match verify_login(public_key, signed) { + Ok(registered) => { + let identity = ®istered.identity; + if self.revoked_keys.peek(&identity.key_id).is_some() { + info!( + log, "refusing login for revoked key"; + "key_id" => %identity.key_id, "actor" => %actor, + ); + } else { + info!( + log, "registered identity"; + "key_id" => %identity.key_id, "actor" => %actor, + ); + let key = (identity.key_id.clone(), identity.nonce.clone()); + self.identities.put(key, registered); + } + } + Err(error) => { + error!( + log, "ignoring invalid login"; + "error" => %error, "actor" => %actor, + ); + } + } + } + (actor, IdentityRequest::Revoke(key_id, when)) => { + let dead: Vec<(KeyId, Nonce)> = self + .identities + .iter() + .filter(|((id, _), _)| id == key_id) + .map(|(key, _)| key.clone()) + .collect(); + for key in dead { + self.identities.pop(&key); + } + self.revoked_keys.put(key_id.clone(), *when); + info!(log, "revoked identity"; "key_id" => %key_id, "actor" => %actor); + } + }, }, V0(Message::Event(baseboard_id, event)) => match event { // Track the active set of known-running jobs anywhere in the rack. Event::Job(job_event) => match job_event { JobEvent::Start(job_id, when) => { info!(log, "job started"; "job_id" => %job_id, "when" => %when); - self.running - .insert((job_id.clone(), baseboard_id.clone()), *when); + self.running.insert((*job_id, baseboard_id.clone()), *when); self.history.set_job_status( job_id, baseboard_id, JobStatus::Started { - job_id: job_id.clone(), + job_id: *job_id, time_started: *when, }, Some(incoming_version.rank()), @@ -667,7 +763,7 @@ impl State { if baseboard_id == &self.own_baseboard { self.attachments.remove(job_id); } - self.running.remove(&(job_id.clone(), baseboard_id.clone())); + self.running.remove(&(*job_id, baseboard_id.clone())); self.history.transition_job_status( job_id, baseboard_id, @@ -675,7 +771,7 @@ impl State { |old_status| match old_status { Some(JobStatus::Started { time_started, .. }) => { Some(JobStatus::Stopped { - job_id: job_id.clone(), + job_id: *job_id, time_started: *time_started, time_stopped: *when, result: result.clone(), @@ -696,12 +792,12 @@ impl State { if baseboard_id == &self.own_baseboard { self.attachments.remove(job_id); } - self.running.remove(&(job_id.clone(), baseboard_id.clone())); + self.running.remove(&(*job_id, baseboard_id.clone())); self.history.set_job_status( job_id, baseboard_id, JobStatus::Error { - job_id: job_id.clone(), + job_id: *job_id, time_error: *when, error: error.clone(), }, @@ -724,6 +820,16 @@ impl State { } } +/// Create a fresh gossip network with this server as its only peer. +/// +/// A peer that seeds its own network has no one to gossip with, so jobs run +/// only on the server that accepted them, and no server learns about any other +/// server's sessions. This stands in for joining the rack's network over +/// sprockets on the bootstrap network. +pub fn seed_gossip() -> GossipNetwork { + Peer::seed().into_rumors() +} + #[derive(Debug)] pub struct StateManager {} @@ -733,6 +839,13 @@ impl StateManager { /// requests, events, or messages can be received. Returns a shared /// `State` that will be asynchronously updated in response to /// messages and events. + /// + /// `universe` follows the gossip network we belong to. When it changes, + /// everything resets. Versions do not compare across universes, so no + /// session or history bookkeeping can survive a migration; running jobs + /// continue, and their events land in the new universe. + /// + /// `cubbies` follows the rack's cubby map, which survives migrations. #[allow(clippy::too_many_arguments)] pub fn run( log: Logger, @@ -740,15 +853,19 @@ impl StateManager { output_dir: JobOutputDir, own_baseboard: BaseboardId, mut requests: R, - rumors: GossipNetwork, + mut cubbies: watch::Receiver, + universe: watch::Receiver, roots: &[Certificate], shutdown: CancellationToken, - ) -> (watch::Receiver, JoinHandle<()>) + ) -> Result<(watch::Receiver, JoinHandle<()>), KeyError> where R: Stream + Send + Unpin + 'static, { // We report our current state through a watch channel. - let (tx_state, rx_state) = watch::channel(State::new(own_baseboard.clone(), roots)); + let mut initial_state = State::new(own_baseboard.clone(), roots)?; + initial_state.cubbies = cubbies.borrow_and_update().clone(); + let (tx_state, rx_state) = watch::channel(initial_state); + let roots = roots.to_vec(); // We process messages in causal order, so that we can rely on // things like "the session stop happens after its corresponding @@ -756,7 +873,8 @@ impl StateManager { // and computation, but makes it much easier to ensure that our // state machine is correct, because it now only has to be correct // in the face of arbitrary *causal* reorderings. - let mut causal_messages = rumors.causal_messages(); + let initial = universe.borrow().clone(); + let mut causal_messages = initial.causal_messages(); // The executor needs to have access to send messages back. let (mut executor, mut events) = Executor::new( @@ -767,16 +885,19 @@ impl StateManager { ); // We will drop this once we want to drain the remaining messages. - let mut rumors = Some(rumors); + // The watch channel behind `universe` stores its own copy of the + // network, so holding the receiver would keep the network from + // draining while we wait for exactly that. + let mut gossip = Some((initial, universe)); - ( + Ok(( rx_state, spawn(async move { info!(log, "managing state"); // These flip both to `true` once our two input streams (local // requests and local events from the executor) terminate or - // we're shutting down. At this point, we must drop `rumors` + // we're shutting down. At this point, we must drop `gossip` // and thereby permit its own `unordered_messages` stream to // eventually be drained; we do this so that we fully update // the local state until nothing more is left to do. @@ -786,16 +907,20 @@ impl StateManager { loop { let message = causal_messages.borrow_next(); - // Once we drain the requests and events, we drop `rumors` so + // Once we drain the requests and events, we drop `gossip` so // that if there are no outstanding copies elsewhere, we will // drain it and then break. // // If there are still gossip sessions happening, those will // complete and we will process their messages into the state. if requests_empty && events_empty { - rumors = None; + gossip = None; } + // Applied after the select, once `message` and the + // universe future have released their borrows. + let mut swap = false; + select! { // Forward local requests into the rumors state, // so they are processed by the state machine. @@ -805,7 +930,7 @@ impl StateManager { // we need to let all spawned tasks by the executor // quiesce, updating the state all the way. None => requests_empty = true, - Some(request) => if let Some(rumors) = &rumors { + Some(request) => if let Some((rumors, _)) = &gossip { debug!(log, "forwarding request to gossip network"; "kind" => request.kind()); rumors.send(Message::Request(request).into()); }, @@ -814,7 +939,7 @@ impl StateManager { // Handle events produced by the executor. next = events.next(), if !events_empty => match next { None => events_empty = true, - Some(event) => if let Some(rumors) = &rumors { + Some(event) => if let Some((rumors, _)) = &gossip { debug!(log, "forwarding event to gossip network"; "event" => ?event); rumors.send(Message::Event(own_baseboard.clone(), event).into()); }, @@ -838,7 +963,7 @@ impl StateManager { tx_state.send_modify(|state| { if let Err(error) = state.update(&log, &mut executor, key, version, message) { error!(log, "state update failed"; "error" => ?error); - if let Some(rumors) = &rumors { + if let Some((rumors, _)) = &gossip { debug!(log, "sending error to gossip network"; "error" => ?error); rumors.send(Message::Event(own_baseboard.clone(), Event::Error(error)).into()); } @@ -847,14 +972,45 @@ impl StateManager { }, }, + // Follow the rack's cubby map. + Ok(()) = cubbies.changed() => { + tx_state.send_modify(|state| { + state.cubbies = cubbies.borrow_and_update().clone(); + }); + }, + + // Follow the gossip manager to a new universe, + // unless we're already draining. + Ok(()) = async { + match gossip.as_mut() { + Some((_, universe)) => universe.changed().await, + None => std::future::pending().await, + } + } => swap = true, + // Stop processing requests on shutdown. _ = shutdown.cancelled(), if !requests_empty => { requests_empty = true; } } + + if swap { + let (rumors, universe) = + gossip.as_mut().expect("gossip present when it changes"); + let fresh = universe.borrow_and_update().clone(); + info!(log, "gossip universe changed, resetting state"; "network" => %fresh.network()); + causal_messages = fresh.causal_messages(); + // TODO: re-inject local job state (policy pending). + tx_state.send_modify(|state| { + *state = State::new(own_baseboard.clone(), &roots) + .expect("roots validated at startup"); + state.cubbies = cubbies.borrow().clone(); + }); + *rumors = fresh; + } } }), - ) + )) } } @@ -990,6 +1146,19 @@ impl State { } } +/// Re-verify gossiped login evidence. Identity gossip carries the +/// signed challenge response, not another sled's conclusion, so a +/// registered identity never depends on that sled's honesty. +fn verify_login( + public_key: &SshPublicKey, + signed: &SignedLogin, +) -> Result { + let verified = signed.clone().verify_with_ssh_public_key(public_key)?; + let verifier = verified.epk().clone(); + let identity = Identity::new(public_key.clone(), verified, Utc::now())?; + Ok(RegisteredIdentity { identity, verifier }) +} + /// Return the cert chain for the given key in root-to-leaf order. /// Will never return an empty chain. pub fn cert_chain(certs: &Certificates, key_id: &KeyId) -> Result, KeyError> { diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs new file mode 100644 index 0000000..1a62c12 --- /dev/null +++ b/server/tests/common/mod.rs @@ -0,0 +1,180 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Shared harness for sprockets-backed tests: a local PKI with fake +//! measurements, exactly as trust-quorum's tests build one. + +#![allow(dead_code)] // each test crate uses a subset + +use std::fs; +use std::net::{Ipv6Addr, SocketAddrV6}; +use std::sync::Arc; +use std::time::Duration; + +use attest_mock::{corim, log}; +use camino::Utf8PathBuf; +use chrono::Utc; +use rand_core::{OsRng, RngCore as _}; +use slog::{Drain as _, Logger, o}; +use slog_term::{FullFormat, PlainSyncDecorator, TestStdoutWriter}; +use sprockets_tls::keys::{ + AttestConfig, MeasurementConnectionPolicy, ResolveSetting, SprocketsConfig, +}; +use sprockets_tls_test_utils::{ + OutputFileExistsBehavior, alias_prefix, cert_path, certlist_path, generate_config, + private_key_path, root_prefix, sprockets_auth_prefix, +}; +use sush_common::authn::{Challenge, ChallengeResponse, Identity, Nonce, RequestKey}; +use sush_common::codephrases::Codephrase; +use sush_common::jobs::{JobId, JobStartRequest, SignedJob}; +use sush_common::keys::{EphemeralKey, KeyType, Signer as _}; +use sush_common::targets::Target; +use sush_server::gossip::GossipConfig; +use sush_server::link::CorpusSource; +use tempfile::TempDir; +use tokio::time::{Instant, sleep}; +use x509_cert::time::Validity; + +pub fn test_logger(test_name: &'static str) -> Logger { + let decorator = PlainSyncDecorator::new(TestStdoutWriter); + let drain = FullFormat::new(decorator).build().fuse(); + Logger::root(drain, o!("test" => test_name)) +} + +pub fn localhost() -> SocketAddrV6 { + SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0) +} + +/// A PKI plus fake measurements for `num_nodes` nodes, in a fresh temp dir. +pub fn pki(prefix: &'static str, num_nodes: usize) -> (TempDir, Utf8PathBuf) { + let tmp = TempDir::with_prefix(prefix).unwrap(); + let dir = Utf8PathBuf::from_path_buf(tmp.path().to_owned()).unwrap(); + write_keys_and_measurements(dir.clone(), num_nodes); + (tmp, dir) +} + +/// Generate a PKI plus fake measurements for `num_nodes` nodes. +pub fn write_keys_and_measurements(dir: Utf8PathBuf, num_nodes: usize) { + let file_behavior = OutputFileExistsBehavior::Overwrite; + let doc = generate_config(num_nodes); + doc.write_key_pairs(dir.clone(), file_behavior).unwrap(); + doc.write_certificates(dir.clone(), file_behavior).unwrap(); + doc.write_certificate_lists(dir.clone(), file_behavior) + .unwrap(); + + let digest = "be4df4e085175f3de0c8ac4837e1c2c9a34e8983209dac6b549e94154f7cdd9c"; + let attest_log_doc = log::Document { + measurements: vec![log::Measurement { + algorithm: "sha3-256".into(), + digest: digest.into(), + }], + }; + let out = log::mock(attest_log_doc).unwrap(); + fs::write(dir.join("log.bin"), &out).unwrap(); + + let corim_doc = corim::Document { + vendor: "Test Bed".into(), + tag_id: "test-v0.0.99999".into(), + id: "corim-test-v0.0.99999".into(), + measurements: vec![ + corim::Measurement { + mkey: "fake-sp".into(), + algorithm: 10, + digest: digest.into(), + }, + corim::Measurement { + mkey: "fake-fwid".into(), + algorithm: 10, + digest: "72fa8f8ea84a42251031366002cbb36281d0131f78cd680436116a720cdd9de5".into(), + }, + ], + }; + let corim = corim::mock(corim_doc).unwrap(); + fs::write(dir.join("corim.cbor"), &corim).unwrap(); +} + +pub fn corpus(dir: &Utf8PathBuf) -> CorpusSource { + let corpus = vec![dir.join("corim.cbor")]; + Arc::new(move || corpus.clone()) +} + +/// Dial ceiling for localhost handshakes. +pub fn dial_timeout() -> Duration { + Duration::from_secs(10) +} + +/// Gossip timing shrunk for localhost. +pub fn gossip_config() -> GossipConfig { + GossipConfig { + reconnect: Duration::from_millis(250), + connect_timeout: Duration::from_secs(10), + join_timeout: Duration::from_secs(30), + } +} + +/// Poll `condition` until it holds or `secs` elapse. +pub async fn eventually(what: &str, secs: u64, mut condition: impl AsyncFnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(secs); + while !condition().await { + assert!(Instant::now() < deadline, "timed out waiting for {what}"); + sleep(Duration::from_millis(100)).await; + } +} + +/// A fresh self-signed job-signing root. +pub fn ephemeral_root() -> EphemeralKey { + let mut buf = [0; 8]; + OsRng.fill_bytes(&mut buf); + let id = Codephrase::random().truncate(); + EphemeralKey::new_root( + KeyType::P256, + format!("CN=Ephemeral Test Key {id},O=Oxide Computer Company,C=US") + .parse() + .unwrap(), + Validity::from_now(Duration::from_secs(600)).unwrap(), + ) + .unwrap() +} + +/// An authenticated identity for `key`, as `iam` would produce. +pub async fn fake_identity(key: &mut EphemeralKey) -> Identity { + let challenge = Challenge::new(Nonce::random()); + let response = ChallengeResponse::new(challenge, RequestKey::new().verifier()); + let signed = key.sign(response).await.unwrap(); + let verified = signed + .verify_with_ssh_public_key(&key.ssh_public_key()) + .unwrap(); + Identity::new(key.ssh_public_key(), verified, Utc::now()).unwrap() +} + +/// Sign a batch job request with `root`. +pub async fn sign_job(root: &mut EphemeralKey, job_id: &JobId, command: &str) -> SignedJob { + root.sign(JobStartRequest::new( + job_id.to_owned(), + command, + false, + Target::All, + )) + .await + .unwrap() +} + +pub fn sprockets_config(dir: &Utf8PathBuf, node: usize) -> SprocketsConfig { + let sprockets_auth_key_name = sprockets_auth_prefix(node); + let alias_key_name = alias_prefix(node); + SprocketsConfig { + resolve: ResolveSetting::Local { + priv_key: private_key_path(dir.clone(), &sprockets_auth_key_name), + cert_chain: certlist_path(dir.clone(), &sprockets_auth_key_name), + }, + attest: AttestConfig::Local { + priv_key: private_key_path(dir.clone(), &alias_key_name), + cert_chain: certlist_path(dir.clone(), &alias_key_name), + log: dir.join("log.bin"), + test_corpus: vec![dir.join("corim.cbor")], + }, + roots: vec![cert_path(dir.clone(), &root_prefix())], + enforce: MeasurementConnectionPolicy::Enforced, + } +} diff --git a/server/tests/distributed.rs b/server/tests/distributed.rs new file mode 100644 index 0000000..9ee6584 --- /dev/null +++ b/server/tests/distributed.rs @@ -0,0 +1,177 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Two job managers on two gossiping sleds. What one accepts, both know. + +mod common; + +use std::collections::BTreeSet; +use std::net::SocketAddrV6; + +use camino::Utf8PathBuf; +use sled_hardware_types::BaseboardId; +use slog::Logger; +use tempfile::TempDir; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +use sush_api::{JobStartParams, JobWait}; +use sush_common::jobs::{JobStatus, Session, SessionId}; +use sush_common::keys::pem_cert_chain; +use sush_common::targets::Cubbies; +use sush_server::executor::PathIsolation; +use sush_server::gossip::spawn_gossip; +use sush_server::output::JobOutputDir; +use sush_server::state::GossipNetwork; +use sush_server::{JobManager, seed_gossip}; + +use common::{ + corpus, eventually, fake_identity, gossip_config, localhost, pki, sign_job, sprockets_config, + test_logger, +}; + +struct Sled { + mgr: JobManager, + universe: watch::Receiver, + peers: watch::Sender>, + addr: SocketAddrV6, + baseboard: BaseboardId, + _output: TempDir, +} + +impl Sled { + async fn start( + log: &Logger, + dir: &Utf8PathBuf, + identity: usize, + root_pem: &Utf8PathBuf, + shutdown: &CancellationToken, + ) -> Sled { + let (peers, peers_rx) = watch::channel(BTreeSet::new()); + let (addr, universe) = spawn_gossip( + log, + gossip_config(), + sprockets_config(dir, identity), + corpus(dir), + localhost(), + peers_rx, + seed_gossip(), + shutdown.clone(), + ) + .await + .unwrap(); + let output = TempDir::with_prefix("sush-out-").unwrap(); + let baseboard = BaseboardId { + part_number: "sled".to_string(), + serial_number: identity.to_string(), + }; + let (_cubbies, cubbies) = watch::channel(Cubbies::new()); + let mgr = JobManager::new( + log.clone(), + PathIsolation::InsecureDisable, + JobOutputDir::fixed(output.path()), + baseboard.clone(), + cubbies, + universe.clone(), + std::slice::from_ref(root_pem), + shutdown.clone(), + ) + .await + .unwrap(); + Sled { + mgr, + universe, + peers, + addr, + baseboard, + _output: output, + } + } +} + +#[tokio::test] +async fn jobs_gossip_between_sleds() { + let (_tmp, dir) = pki("sush-distributed-", 2); + let mut root = common::ephemeral_root(); + let root_pem = dir.join("job-root.pem"); + std::fs::write( + &root_pem, + pem_cert_chain(vec![root.cert().to_owned()]).unwrap(), + ) + .unwrap(); + + let log = test_logger("jobs_gossip_between_sleds"); + let shutdown = CancellationToken::new(); + let a = Sled::start(&log, &dir, 1, &root_pem, &shutdown).await; + let b = Sled::start(&log, &dir, 2, &root_pem, &shutdown).await; + a.peers.send(BTreeSet::from([b.addr])).unwrap(); + b.peers.send(BTreeSet::from([a.addr])).unwrap(); + + // The sleds converge on one universe, resetting the losing job manager. + eventually("universe convergence", 120, async || { + a.universe.borrow().network() == b.universe.borrow().network() + }) + .await; + + // A session started on sled A becomes B's active session too. + let authn_a = fake_identity(&mut root).await; + let authn_b = fake_identity(&mut root).await; + let session_id = SessionId::random(); + let session = Session::new(session_id); + a.mgr + .session_start(&authn_a, session_id, true) + .await + .unwrap(); + eventually("session gossips to B", 60, async || { + b.mgr + .session(&authn_b) + .is_some_and(|s| s.session_id() == session_id) + }) + .await; + + // A job submitted to A runs on both sleds, and each sled learns the + // other's result. + let job_id = session.next_job_id(); + let job = sign_job(&mut root, &job_id, "true").await; + a.mgr + .job_start( + &authn_a, + job, + JobStartParams { + wait: JobWait::Stop, + ..Default::default() + }, + ) + .await + .unwrap(); + let stopped = async |mgr: &JobManager, authn, baseboard: &BaseboardId| { + mgr.job_status(authn, &job_id).await.is_ok_and(|map| { + map.get(baseboard) + .is_some_and(|s| matches!(s, JobStatus::Stopped { .. })) + }) + }; + eventually("A's run visible on B", 60, async || { + stopped(&b.mgr, &authn_b, &a.baseboard).await + }) + .await; + eventually("B's run visible on A", 60, async || { + stopped(&a.mgr, &authn_a, &b.baseboard).await + }) + .await; + + // A session started on B supersedes A's everywhere. + let successor = SessionId::random(); + b.mgr + .session_start(&authn_b, successor, true) + .await + .unwrap(); + eventually("supersession gossips to A", 60, async || { + a.mgr + .session(&authn_a) + .is_some_and(|s| s.session_id() == successor) + }) + .await; + + shutdown.cancel(); +} diff --git a/server/tests/gossip.rs b/server/tests/gossip.rs new file mode 100644 index 0000000..6a4b2dc --- /dev/null +++ b/server/tests/gossip.rs @@ -0,0 +1,174 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! The gossip manager converging localhost meshes, with real attested +//! handshakes throughout. + +mod common; + +use std::collections::BTreeSet; +use std::net::SocketAddrV6; + +use camino::Utf8PathBuf; +use rumors::{Network, Peer, Rumors}; +use slog::Logger; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +use sush_server::gossip::spawn_gossip; + +use common::{corpus, eventually, gossip_config, localhost, pki, sprockets_config, test_logger}; + +struct Node { + addr: SocketAddrV6, + initial: Network, + peers: watch::Sender>, + universe: watch::Receiver>, + shutdown: CancellationToken, +} + +impl Node { + async fn start(log: &Logger, dir: &Utf8PathBuf, identity: usize) -> Node { + let shutdown = CancellationToken::new(); + let seed: Rumors = Peer::seed().into_rumors(); + let initial = seed.network(); + let (peers, peers_rx) = watch::channel(BTreeSet::new()); + let (addr, universe) = spawn_gossip( + log, + gossip_config(), + sprockets_config(dir, identity), + corpus(dir), + localhost(), + peers_rx, + seed, + shutdown.clone(), + ) + .await + .unwrap(); + Node { + addr, + initial, + peers, + universe, + shutdown, + } + } + + fn network(&self) -> Network { + self.universe.borrow().network() + } + + fn rumors(&self) -> Rumors { + self.universe.borrow().clone() + } + + fn contains(&self, message: &str) -> bool { + self.rumors() + .snapshot() + .iter() + .any(|(_, _, m)| m.as_str() == message) + } +} + +impl Drop for Node { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +/// Point every node at every other. +fn mesh(nodes: &[&Node]) { + let all: BTreeSet<_> = nodes.iter().map(|n| n.addr).collect(); + for node in nodes { + let mut peers = all.clone(); + peers.remove(&node.addr); + node.peers.send(peers).unwrap(); + } +} + +/// The single network every node agrees on, if they agree. +fn converged(nodes: &[&Node]) -> Option { + let first = nodes.first()?.network(); + nodes.iter().all(|n| n.network() == first).then_some(first) +} + +#[tokio::test] +async fn cold_start_converges() { + let (_tmp, dir) = pki("sush-gossip-", 3); + let log = test_logger("cold_start_converges"); + let a = Node::start(&log, &dir, 1).await; + let b = Node::start(&log, &dir, 2).await; + let c = Node::start(&log, &dir, 3).await; + let nodes = [&a, &b, &c]; + let min = nodes.iter().map(|n| n.initial).min().unwrap(); + mesh(&nodes); + + eventually("universe convergence", 120, async || { + converged(&nodes) == Some(min) + }) + .await; + + a.rumors().send("hello".to_string()); + eventually("message everywhere", 60, async || { + nodes.iter().all(|n| n.contains("hello")) + }) + .await; +} + +#[tokio::test] +async fn staggered_start_converges() { + let (_tmp, dir) = pki("sush-gossip-", 3); + let log = test_logger("staggered_start_converges"); + let a = Node::start(&log, &dir, 1).await; + let b = Node::start(&log, &dir, 2).await; + mesh(&[&a, &b]); + eventually("pair convergence", 120, async || { + converged(&[&a, &b]).is_some() + }) + .await; + + let c = Node::start(&log, &dir, 3).await; + let nodes = [&a, &b, &c]; + mesh(&nodes); + let min = nodes.iter().map(|n| n.initial).min().unwrap(); + eventually("universe convergence", 120, async || { + converged(&nodes) == Some(min) + }) + .await; + + c.rumors().send("late but heard".to_string()); + eventually("message everywhere", 60, async || { + nodes.iter().all(|n| n.contains("late but heard")) + }) + .await; +} + +#[tokio::test] +async fn node_replacement_reconverges() { + let (_tmp, dir) = pki("sush-gossip-", 4); + let log = test_logger("node_replacement_reconverges"); + let a = Node::start(&log, &dir, 1).await; + let b = Node::start(&log, &dir, 2).await; + let c = Node::start(&log, &dir, 3).await; + mesh(&[&a, &b, &c]); + eventually("initial convergence", 120, async || { + converged(&[&a, &b, &c]).is_some() + }) + .await; + + // Node b dies; a fresh node (new identity, new seed, no persistence) + // takes its place in the mesh. + b.shutdown.cancel(); + drop(b); + let d = Node::start(&log, &dir, 4).await; + let nodes = [&a, &c, &d]; + mesh(&nodes); + eventually("re-convergence", 120, async || converged(&nodes).is_some()).await; + + a.rumors().send("after the funeral".to_string()); + eventually("message everywhere", 60, async || { + nodes.iter().all(|n| n.contains("after the funeral")) + }) + .await; +} diff --git a/server/tests/link.rs b/server/tests/link.rs new file mode 100644 index 0000000..e3fbc02 --- /dev/null +++ b/server/tests/link.rs @@ -0,0 +1,146 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! The sprockets transport against the rumors link contract, and a +//! two-peer gossip smoke test over it. + +mod common; + +use std::time::Duration; + +use camino::Utf8PathBuf; +use futures::StreamExt as _; +use futures::stream; +use slog::Logger; +use tempfile::TempDir; +use tokio::time::timeout; +use tokio::{join, spawn}; +use tokio_util::sync::CancellationToken; + +use rumors::conformance::link::check; +use rumors::{Peer, Rumors}; +use sush_server::link::{SprocketsLink, Transport}; + +use common::{corpus, dial_timeout, localhost, pki, sprockets_config, test_logger}; + +struct TestNet { + a: Transport, + b: Transport, + shutdown: CancellationToken, + _dir: TempDir, +} + +async fn transport( + log: &Logger, + dir: &Utf8PathBuf, + identity: usize, + shutdown: &CancellationToken, +) -> Transport { + Transport::new( + log, + sprockets_config(dir, identity), + corpus(dir), + localhost(), + dial_timeout(), + shutdown.clone(), + ) + .await + .unwrap() +} + +impl TestNet { + async fn new(test_name: &'static str) -> TestNet { + let (tmp, dir) = pki("sush-link-", 2); + let log = test_logger(test_name); + let shutdown = CancellationToken::new(); + let a = transport(&log, &dir, 1, &shutdown).await; + let b = transport(&log, &dir, 2, &shutdown).await; + TestNet { + a, + b, + shutdown, + _dir: tmp, + } + } + + /// One fresh connected link pair: node 1 establishes, node 2 accepts. + async fn link_pair(&mut self) -> (SprocketsLink, SprocketsLink) { + let endpoint = self.a.endpoint(); + let peer = *self.b.endpoint().local_addr(); + let (linked, accepted) = join!(endpoint.link(peer), self.b.accept()); + let link_a = linked.expect("peer router accepts the link"); + let (from, link_b) = accepted.expect("router is live"); + assert_eq!(from, *endpoint.local_addr()); + (link_a, link_b) + } +} + +impl Drop for TestNet { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +// Slow (~35s each): CI always runs these, and local runs should +// whenever `link.rs` or the gossip configuration changes: +// +// cargo test --package sush-server --test link -- --include-ignored + +#[tokio::test] +#[ignore] +async fn conformance() { + let mut net = TestNet::new("conformance").await; + timeout( + Duration::from_secs(600), + check(async || net.link_pair().await), + ) + .await + .expect("conformance suite timed out"); +} + +#[tokio::test] +#[ignore] +async fn gossip_convergence() { + let mut net = TestNet::new("gossip_convergence").await; + let (link_a, mut link_b) = net.link_pair().await; + + // Alice seeds a universe with one message and serves sessions on her + // end of the link. + let alice: Rumors = Peer::seed().into_rumors(); + alice.send("from alice".to_string()); + let server = spawn({ + let alice = alice.clone(); + async move { + let mut link_a = link_a; + let mut driver = alice.gossip_when(stream::pending::<()>(), &mut link_a); + while let Some(session) = driver.next().await { + session.expect("serving gossip session"); + } + } + }); + + // Bob joins Alice's universe through the link and hears her message. + let bob = timeout( + Duration::from_secs(60), + Peer::::bootstrap().join(&mut link_b), + ) + .await + .expect("bootstrap timed out") + .expect("bootstrap failed") + .expect("mutual bootstrap bail") + .into_rumors(); + assert_eq!(bob.network(), alice.network()); + assert_eq!(bob.snapshot().len(), 1); + + // Bob's own message reaches Alice within one gossip session. + bob.send("from bob".to_string()); + timeout(Duration::from_secs(60), bob.gossip(&mut link_b)) + .await + .expect("gossip timed out") + .expect("gossip failed"); + assert_eq!(bob.snapshot().len(), 2); + assert_eq!(alice.snapshot().len(), 2); + + server.abort(); +} diff --git a/server/tests/output/identity-login-request.bin b/server/tests/output/identity-login-request.bin new file mode 100644 index 0000000..90e9242 Binary files /dev/null and b/server/tests/output/identity-login-request.bin differ diff --git a/server/tests/output/job-start-request.bin b/server/tests/output/job-start-request.bin new file mode 100644 index 0000000..3fa78f1 Binary files /dev/null and b/server/tests/output/job-start-request.bin differ diff --git a/server/tests/output/session-allow-attach-request.bin b/server/tests/output/session-allow-attach-request.bin new file mode 100644 index 0000000..9ce5e5a Binary files /dev/null and b/server/tests/output/session-allow-attach-request.bin differ diff --git a/server/tests/output/session-deny-attach-request.bin b/server/tests/output/session-deny-attach-request.bin new file mode 100644 index 0000000..0eacba1 Binary files /dev/null and b/server/tests/output/session-deny-attach-request.bin differ diff --git a/server/tests/output/session-start-request.bin b/server/tests/output/session-start-request.bin new file mode 100644 index 0000000..cd0df69 Binary files /dev/null and b/server/tests/output/session-start-request.bin differ diff --git a/server/tests/output/session-stop-request.bin b/server/tests/output/session-stop-request.bin new file mode 100644 index 0000000..469491a Binary files /dev/null and b/server/tests/output/session-stop-request.bin differ diff --git a/sush.json b/sush.json index 7495feb..5e8a215 100644 --- a/sush.json +++ b/sush.json @@ -196,6 +196,15 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "via", + "description": "Where a proxy should route this request.", + "schema": { + "nullable": true, + "type": "string" + } } ], "requestBody": { @@ -233,7 +242,7 @@ "/iam/{key_id}/revoke": { "post": { "summary": "Revoke an SSH identity.", - "description": "Expires the key's cached identities and refuses its future logins. Identities are not shared across the rack, so this applies only to the server handling the request.", + "description": "Expires the key's identities and refuses its future logins, across the rack.", "operationId": "iam_revoke", "parameters": [ { @@ -251,6 +260,14 @@ "schema": { "$ref": "#/components/schemas/KeyId" } + }, + { + "in": "query", + "name": "wait", + "description": "Wait for the subject to appear in the state.", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -357,16 +374,26 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "via", + "description": "Where a proxy should route this request.", + "schema": { + "nullable": true, + "type": "string" + } } ], "responses": { - "default": { - "description": "", - "content": { - "*/*": { - "schema": {} - } - } + "101": { + "description": "Negotiating protocol upgrade from HTTP/1.1 to WebSocket" + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" } }, "x-dropshot-websocket": {} @@ -403,18 +430,27 @@ }, { "in": "path", - "name": "target", + "name": "stream", "required": true, "schema": { - "type": "string" + "$ref": "#/components/schemas/JobOutputStream" } }, { "in": "path", - "name": "stream", + "name": "target", "required": true, "schema": { - "$ref": "#/components/schemas/JobOutputStream" + "type": "string" + } + }, + { + "in": "query", + "name": "via", + "description": "Where a proxy should route this request.", + "schema": { + "nullable": true, + "type": "string" } } ], @@ -451,26 +487,6 @@ "$ref": "#/components/schemas/JobId" } }, - { - "in": "query", - "name": "term", - "description": "Terminal type for interactive jobs.", - "schema": { - "nullable": true, - "type": "string" - } - }, - { - "in": "query", - "name": "rows", - "description": "Terminal window height for interactive jobs.", - "schema": { - "nullable": true, - "type": "integer", - "format": "uint16", - "minimum": 0 - } - }, { "in": "query", "name": "cols", @@ -484,16 +500,19 @@ }, { "in": "query", - "name": "wait", - "description": "Wait for the job to start or stop.", + "name": "max_cpu", + "description": "Maximum CPU use in seconds.", + "required": true, "schema": { - "$ref": "#/components/schemas/JobWait" + "type": "integer", + "format": "uint64", + "minimum": 0 } }, { "in": "query", - "name": "max_cpu", - "description": "Maximum CPU use in seconds.", + "name": "max_fsize", + "description": "Maximum file size in bytes.", "required": true, "schema": { "type": "integer", @@ -514,14 +533,31 @@ }, { "in": "query", - "name": "max_fsize", - "description": "Maximum file size in bytes.", - "required": true, + "name": "rows", + "description": "Terminal window height for interactive jobs.", "schema": { + "nullable": true, "type": "integer", - "format": "uint64", + "format": "uint16", "minimum": 0 } + }, + { + "in": "query", + "name": "term", + "description": "Terminal type for interactive jobs.", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "in": "query", + "name": "wait", + "description": "Wait for the job to start or stop.", + "schema": { + "$ref": "#/components/schemas/JobWait" + } } ], "requestBody": { @@ -686,18 +722,18 @@ }, { "in": "path", - "name": "session_id", + "name": "key_id", "required": true, "schema": { - "$ref": "#/components/schemas/SessionId" + "$ref": "#/components/schemas/KeyId" } }, { "in": "path", - "name": "key_id", + "name": "session_id", "required": true, "schema": { - "$ref": "#/components/schemas/KeyId" + "$ref": "#/components/schemas/SessionId" } }, { @@ -738,18 +774,18 @@ }, { "in": "path", - "name": "session_id", + "name": "key_id", "required": true, "schema": { - "$ref": "#/components/schemas/SessionId" + "$ref": "#/components/schemas/KeyId" } }, { "in": "path", - "name": "key_id", + "name": "session_id", "required": true, "schema": { - "$ref": "#/components/schemas/KeyId" + "$ref": "#/components/schemas/SessionId" } } ], @@ -781,18 +817,18 @@ }, { "in": "path", - "name": "session_id", + "name": "job_id", "required": true, "schema": { - "$ref": "#/components/schemas/SessionId" + "$ref": "#/components/schemas/JobId" } }, { "in": "path", - "name": "job_id", + "name": "session_id", "required": true, "schema": { - "$ref": "#/components/schemas/JobId" + "$ref": "#/components/schemas/SessionId" } } ], @@ -891,13 +927,15 @@ "/target": { "get": { "summary": "Get the baseboard ID of the sled handling this request.", + "description": "Unauthenticated: discovery must work before anyone can log in, and this reveals only the sled's baseboard. Routed through a proxy, this resolves a target expression to a baseboard.", "operationId": "target", "parameters": [ { - "in": "header", - "name": "authorization", - "description": "Authorization to access some resource, such as a signature.\n\nSee: ", + "in": "query", + "name": "via", + "description": "Where a proxy should route this request.", "schema": { + "nullable": true, "type": "string" } } @@ -925,10 +963,58 @@ }, "components": { "schemas": { - "KeyId": { - "description": "SHA-256 of a certificate subject or an identity public key, encoded as a pseudorandom code phrase for storage & transport.", + "BaseboardId": { + "description": "A representation of a Baseboard ID as used in the inventory subsystem.\n\nThis type is essentially the same as a `Baseboard` except it doesn't have a revision or HW type (Gimlet, PC, Unknown).", + "type": "object", + "properties": { + "part_number": { + "description": "Oxide Part Number", + "type": "string" + }, + "serial_number": { + "description": "Serial number (unique for a given part number)", + "type": "string" + } + }, + "required": [ + "part_number", + "serial_number" + ] + }, + "EccR": { + "description": "The first half of a 256 bit elliptic-curve signature: the scalar `r` of an ECDSA pair, or the encoded point `R` of an Ed25519 signature. It is carried as an opaque 256 bit value to which only the signature algorithm assigns meaning.", + "type": "string" + }, + "EccS": { + "description": "The second half of a 256 bit elliptic-curve signature: the scalar `s` of an ECDSA pair, or the little-endian scalar `S` of an Ed25519 signature. It is carried as an opaque 256 bit value like [`EccR`].", "type": "string" }, + "EncodedSignature": { + "description": "Code phrase encoded signature.\n\nThe decoded phrases `r` and `s` together comprise a signature _(r, s)_ over a 256 bit elliptic curve.\n\nThe `flags` and `counter` parameters are for the [`SK-*` family of SSH public keys](https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.u2f?annotate=HEAD). They should be set to 0 for other key types.", + "type": "object", + "properties": { + "counter": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "flags": { + "type": "integer", + "format": "uint8", + "minimum": 0 + }, + "r": { + "$ref": "#/components/schemas/EccR" + }, + "s": { + "$ref": "#/components/schemas/EccS" + } + }, + "required": [ + "r", + "s" + ] + }, "Error": { "description": "Error information from a response.", "type": "object", @@ -955,12 +1041,12 @@ "key_id": { "$ref": "#/components/schemas/KeyId" }, - "public_key": { - "type": "string" - }, "nonce": { "$ref": "#/components/schemas/Nonce" }, + "public_key": { + "type": "string" + }, "time_authenticated": { "type": "string", "format": "date-time" @@ -978,10 +1064,73 @@ "time_authenticated" ] }, - "Nonce": { - "description": "A unique random string. Authentication credentials have two of these: one generated by the server, and one by the client. This structure is agnostic to the syntax of the string.", + "JobId": { + "description": "A globally unique identifier for a job within a session.", "type": "string" }, + "JobOutputHash": { + "description": "BLAKE3 hash of job output, used as a checksum.", + "type": "string", + "format": "hex encoded hash (32 bytes)" + }, + "JobOutputState": { + "type": "object", + "properties": { + "stderr_hash": { + "$ref": "#/components/schemas/JobOutputHash" + }, + "stderr_len": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "stdout_hash": { + "$ref": "#/components/schemas/JobOutputHash" + }, + "stdout_len": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "stderr_hash", + "stderr_len", + "stdout_hash", + "stdout_len" + ] + }, + "JobOutputStream": { + "description": "Either the standard output or standard error of a job.", + "type": "string", + "enum": [ + "stdout", + "stderr" + ] + }, + "JobStartRequest": { + "description": "A request to run the given `command` as `job_id`.", + "type": "object", + "properties": { + "command": { + "type": "string" + }, + "interactive": { + "type": "boolean" + }, + "job_id": { + "$ref": "#/components/schemas/JobId" + }, + "target": { + "description": "The sleds this job runs on.", + "type": "string" + } + }, + "required": [ + "command", + "job_id" + ] + }, "JobStatus": { "oneOf": [ { @@ -990,13 +1139,6 @@ "Cancelled": { "type": "object", "properties": { - "job_id": { - "$ref": "#/components/schemas/JobId" - }, - "time_cancelled": { - "type": "string", - "format": "date-time" - }, "actor": { "description": "The key that requested the cancellation.", "allOf": [ @@ -1004,6 +1146,13 @@ "$ref": "#/components/schemas/KeyId" } ] + }, + "job_id": { + "$ref": "#/components/schemas/JobId" + }, + "time_cancelled": { + "type": "string", + "format": "date-time" } }, "required": [ @@ -1024,13 +1173,6 @@ "Queued": { "type": "object", "properties": { - "job_id": { - "$ref": "#/components/schemas/JobId" - }, - "time_queued": { - "type": "string", - "format": "date-time" - }, "actor": { "description": "The key that submitted the job.", "allOf": [ @@ -1038,6 +1180,13 @@ "$ref": "#/components/schemas/KeyId" } ] + }, + "job_id": { + "$ref": "#/components/schemas/JobId" + }, + "time_queued": { + "type": "string", + "format": "date-time" } }, "required": [ @@ -1058,15 +1207,15 @@ "Error": { "type": "object", "properties": { + "error": { + "$ref": "#/components/schemas/ProcessError" + }, "job_id": { "$ref": "#/components/schemas/JobId" }, "time_error": { "type": "string", "format": "date-time" - }, - "error": { - "$ref": "#/components/schemas/ProcessError" } }, "required": [ @@ -1115,6 +1264,12 @@ "job_id": { "$ref": "#/components/schemas/JobId" }, + "output": { + "$ref": "#/components/schemas/JobOutputState" + }, + "result": { + "$ref": "#/components/schemas/Result_of_int32_or_ProcessError" + }, "time_started": { "type": "string", "format": "date-time" @@ -1122,12 +1277,6 @@ "time_stopped": { "type": "string", "format": "date-time" - }, - "result": { - "$ref": "#/components/schemas/Result_of_int32_or_ProcessError" - }, - "output": { - "$ref": "#/components/schemas/JobOutputState" } }, "required": [ @@ -1146,8 +1295,46 @@ } ] }, - "JobId": { - "description": "A globally unique identifier for a job within a session.", + "KeyId": { + "description": "SHA-256 of a certificate subject or an identity public key, encoded as a pseudorandom code phrase for storage & transport.", + "type": "string" + }, + "LastJob": { + "oneOf": [ + { + "type": "string", + "enum": [ + "None" + ] + }, + { + "type": "object", + "properties": { + "Some": { + "$ref": "#/components/schemas/Signed_for_JobStartRequest" + } + }, + "required": [ + "Some" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "Burned": { + "$ref": "#/components/schemas/JobId" + } + }, + "required": [ + "Burned" + ], + "additionalProperties": false + } + ] + }, + "Nonce": { + "description": "A unique random string. Authentication credentials have two of these: one generated by the server, and one by the client. This structure is agnostic to the syntax of the string.", "type": "string" }, "ProcessError": { @@ -1203,10 +1390,10 @@ "Io": { "type": "object", "properties": { - "what": { + "error": { "type": "string" }, - "error": { + "what": { "type": "string" } }, @@ -1227,13 +1414,13 @@ "OutputLimitExceeded": { "type": "object", "properties": { - "stream": { - "$ref": "#/components/schemas/JobOutputStream" - }, "limit": { "type": "integer", "format": "uint64", "minimum": 0 + }, + "stream": { + "$ref": "#/components/schemas/JobOutputStream" } }, "required": [ @@ -1261,14 +1448,6 @@ } ] }, - "JobOutputStream": { - "description": "Either the standard output or standard error of a job.", - "type": "string", - "enum": [ - "stdout", - "stderr" - ] - }, "Result_of_int32_or_ProcessError": { "oneOf": [ { @@ -1296,112 +1475,15 @@ } ] }, - "JobOutputState": { - "type": "object", - "properties": { - "stdout_len": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "stderr_len": { - "type": "integer", - "format": "uint64", - "minimum": 0 - }, - "stdout_hash": { - "$ref": "#/components/schemas/JobOutputHash" - }, - "stderr_hash": { - "$ref": "#/components/schemas/JobOutputHash" - } - }, - "required": [ - "stderr_hash", - "stderr_len", - "stdout_hash", - "stdout_len" - ] - }, - "JobOutputHash": { - "description": "BLAKE3 hash of job output, used as a checksum.", - "type": "string", - "format": "hex encoded hash (32 bytes)" - }, - "Signed_for_JobStartRequest": { - "description": "A signed envelope around some data.", - "type": "object", - "properties": { - "payload": { - "$ref": "#/components/schemas/JobStartRequest" - }, - "key_id": { - "$ref": "#/components/schemas/KeyId" - }, - "signature": { - "$ref": "#/components/schemas/EncodedSignature" - } - }, - "required": [ - "key_id", - "payload", - "signature" - ] - }, - "JobStartRequest": { - "description": "A request to run the given `command` as `job_id`.", - "type": "object", - "properties": { - "job_id": { - "$ref": "#/components/schemas/JobId" - }, - "command": { - "type": "string" - }, - "interactive": { - "type": "boolean" - } - }, - "required": [ - "command", - "job_id" - ] - }, - "EncodedSignature": { - "description": "Code phrase encoded signature.\n\nThe decoded phrases `r` and `s` together comprise a signature _(r, s)_ over a 256 bit elliptic curve.\n\nThe `flags` and `counter` parameters are for the [`SK-*` family of SSH public keys](https://cvsweb.openbsd.org/src/usr.bin/ssh/PROTOCOL.u2f?annotate=HEAD). They should be set to 0 for other key types.", - "type": "object", - "properties": { - "r": { - "type": "string" - }, - "s": { - "type": "string" - }, - "flags": { - "type": "integer", - "format": "uint8", - "minimum": 0 - }, - "counter": { - "type": "integer", - "format": "uint32", - "minimum": 0 - } - }, - "required": [ - "r", - "s" - ] - }, "Session": { "type": "object", "properties": { - "session_id": { - "$ref": "#/components/schemas/SessionId" - }, "last_job": { "$ref": "#/components/schemas/LastJob" }, + "session_id": { + "$ref": "#/components/schemas/SessionId" + }, "started_by": { "nullable": true, "description": "The key that started the session, where known. Client-side trackers leave it `None`. Servers record the verified starter.", @@ -1421,56 +1503,24 @@ "description": "A globally unique identifier for a session.", "type": "string" }, - "LastJob": { - "oneOf": [ - { - "type": "string", - "enum": [ - "None" - ] - }, - { - "type": "object", - "properties": { - "Some": { - "$ref": "#/components/schemas/Signed_for_JobStartRequest" - } - }, - "required": [ - "Some" - ], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "Burned": { - "$ref": "#/components/schemas/JobId" - } - }, - "required": [ - "Burned" - ], - "additionalProperties": false - } - ] - }, - "BaseboardId": { - "description": "A representation of a Baseboard ID as used in the inventory subsystem.\n\nThis type is essentially the same as a `Baseboard` except it doesn't have a revision or HW type (Gimlet, PC, Unknown).", + "Signed_for_JobStartRequest": { + "description": "A signed envelope around some data.", "type": "object", "properties": { - "part_number": { - "description": "Oxide Part Number", - "type": "string" + "key_id": { + "$ref": "#/components/schemas/KeyId" }, - "serial_number": { - "description": "Serial number (unique for a given part number)", - "type": "string" + "payload": { + "$ref": "#/components/schemas/JobStartRequest" + }, + "signature": { + "$ref": "#/components/schemas/EncodedSignature" } }, "required": [ - "part_number", - "serial_number" + "key_id", + "payload", + "signature" ] }, "JobWait": { diff --git a/tests/Cargo.toml b/tests/Cargo.toml index d9ba2b4..3df7cdf 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -9,17 +9,22 @@ publish = false [dependencies] bytes.workspace = true +camino.workspace = true chrono.workspace = true dropshot.workspace = true +ed25519-dalek.workspace = true function_name.workspace = true futures.workspace = true http-range-header.workspace = true pwd.workspace = true rand_core.workspace = true rumors.workspace = true +sha3.workspace = true sled-hardware-types.workspace = true slog.workspace = true slog-term.workspace = true +sprockets-tls.workspace = true +sprockets-tls-test-utils.workspace = true sush-api = { path = "../api" } sush-client = { path = "../client" } sush-common = { path = "../common" } diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index c6a6ad9..f11df1c 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -4,7 +4,11 @@ //! Oxide Support Shell integration tests. +use std::collections::BTreeMap; +use std::convert::Infallible; +use std::fs::{read, read_to_string, write}; use std::net::SocketAddr; +use std::sync::Arc; use std::time::Duration; use bytes::{Bytes, BytesMut}; @@ -12,22 +16,40 @@ use dropshot::{ConfigDropshot, ServerBuilder}; use function_name::named; use futures::{SinkExt as _, StreamExt as _}; use tokio::io::{AsyncRead, AsyncWrite}; +use tokio::sync::watch; use tokio::test; use tokio::time::timeout; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::tungstenite::protocol::Role; use tokio_util::sync::CancellationToken; +use ed25519_dalek::pkcs8::DecodePrivateKey as _; +use ed25519_dalek::{Signer as _, SigningKey}; +use sha3::{Digest as _, Sha3_256}; +use sprockets_tls::keys::ResolveSetting; +use sprockets_tls_test_utils::{ + cert_path, certlist_path, private_key_path, root_prefix, sprockets_auth_prefix, +}; +use x509_cert::Certificate; +use x509_cert::der::DecodePem as _; +use x509_cert::der::oid::db::rfc8410::ID_ED_25519; +use x509_cert::spki::AlgorithmIdentifierOwned; +use x509_cert::time::Validity; + use sush_api::JobWait; use sush_api::sush_api_mod::api_description; +use sush_client::tls::client as tls_client; use sush_client::{AuthzSigner, Client, Error as ClientError}; use sush_common::interactive::{InteractiveJobControl, InteractiveJobMessage}; use sush_common::jobs::{Access, JobLimits, JobOutputStream, Session, SessionId}; +use sush_common::keys::{EphemeralKey, KeyType, pem_cert_chain}; +use sush_common::targets::Cubbies; +use sush_server::proxy::{Targets, platform_tls}; use sush_server::{ApiServer, ProxyServer}; use crate::test_utils::{ SignJobRequest as _, authz, ephemeral_test_root, manager_and_test_root, test_baseboard_id, - test_logger, + test_logger, test_pki, }; const TIMEOUT: Duration = Duration::from_secs(10); @@ -43,7 +65,7 @@ async fn client_server() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log.clone()).await; let api = api_description::().unwrap(); - let server = ServerBuilder::new(api, mgr, log) + let server = ServerBuilder::new(api, Arc::new(mgr), log) .config(ConfigDropshot { bind_address: local_addr(), ..Default::default() @@ -72,7 +94,7 @@ async fn client_server() { assert_eq!(iam, identity, "who am I?"); // Start a session and run a job. - let session = Session::new(SessionId::new()); + let session = Session::new(SessionId::random()); client .session_start() .session_id(session.session_id()) @@ -90,7 +112,7 @@ async fn client_server() { } = JobLimits::default(); client .job_start() - .job_id(&job_id) + .job_id(job_id) .max_cpu(max_cpu) .max_mem(max_mem) .max_fsize(max_fsize) @@ -103,7 +125,7 @@ async fn client_server() { // Check the job output. let mut output = client .job_output() - .job_id(&job_id) + .job_id(job_id) .stream(JobOutputStream::Stdout) .target("*") .send() @@ -127,7 +149,7 @@ async fn client_proxy_server() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log.clone()).await; let api = api_description::().unwrap(); - let server = ServerBuilder::new(api, mgr, log.clone()) + let server = ServerBuilder::new(api, Arc::new(mgr), log.clone()) .config(ConfigDropshot { bind_address: local_addr(), ..Default::default() @@ -136,22 +158,175 @@ async fn client_proxy_server() { .expect("failed to start server"); let server_addr = server.local_addr(); - // Spin up a proxy server. + // Spin up a proxy server routing to it. + let (tx_targets, rx_targets) = watch::channel(Targets { + sleds: BTreeMap::from([(test_baseboard_id(), server_addr)]), + cubbies: Cubbies::from([(14, test_baseboard_id())]), + }); + let shutdown_proxy = CancellationToken::new(); + let proxy = ProxyServer::start(&log, local_addr(), None, rx_targets, shutdown_proxy.clone()) + .await + .expect("can't start proxy server"); + let proxy_addr = proxy.local_addr(); + assert_ne!(server_addr, proxy_addr); + + // Connect and authenticate to the server via the proxy. + let signer = AuthzSigner::default(); + let client = Client::new(&format!("http://{proxy_addr}"), signer.clone()); + let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() + else { + panic!("expected error response") + }; + assert_eq!(unauthz.status(), 401, "expected 401 Unauthorized"); + let (identity, credentials) = authz(&client, unauthz, &mut root).await; + signer.set(Some(credentials)); + let iam = client + .iam() + .body(None) + .send() + .await + .expect("can't authenticate") + .into_inner(); + assert_eq!(iam, identity, "who am I?"); + + // Attach to an interactive job through the proxy, routed by the + // target path segment, and echo bytes over the bridged upgrade. + let session = Session::new(SessionId::random()); + client + .session_start() + .session_id(session.session_id()) + .send() + .await + .expect("can't start session"); + let job_id = session.next_job_id(); + let job = root + .sign_job_request(&job_id, "cat > /dev/null", true) + .await; + let JobLimits { + max_cpu, + max_mem, + max_fsize, + } = JobLimits::default(); + client + .job_start() + .job_id(job_id) + .max_cpu(max_cpu) + .max_mem(max_mem) + .max_fsize(max_fsize) + .rows(24) + .cols(80) + .wait(JobWait::Start) + .body(job.into_signed()) + .send() + .await + .expect("can't start job"); + let socket = client + .job_attach() + .job_id(job_id) + .target(test_baseboard_id().to_string()) + .send() + .await + .expect("can't attach via proxy") + .into_inner(); + let mut stream = WebSocketStream::from_raw_socket(socket, Role::Client, None).await; + let InteractiveJobControl::WindowChange(_) = next_control_message(&mut stream).await; + let hello = Bytes::from(format!("Hello, {job_id}!")); + stream + .send( + InteractiveJobMessage::Data(hello.clone()) + .try_into() + .expect("can't encode message"), + ) + .await + .expect("can't send message"); + assert_eq!(next_data_message(&mut stream).await, &hello); + + // A target the proxy cannot route is refused at the proxy. + let Err(unrouted) = client + .job_output() + .job_id(job_id) + .stream(JobOutputStream::Stdout) + .target("913-0000019:BRM99999999") + .send() + .await + else { + panic!("expected no route to an unknown baseboard"); + }; + if let ClientError::UnexpectedResponse(response) = unrouted { + assert_eq!(response.status(), 502); + } + + // `/target` needs no login and, through the proxy, resolves + // routing expressions. + let anon = Client::new(&format!("http://{proxy_addr}"), AuthzSigner::default()); + let resolved = anon + .target() + .via("14") + .send() + .await + .expect("can't resolve a cubby via the proxy") + .into_inner(); + assert_eq!(resolved, test_baseboard_id()); + + // With no sleds in the table, everything is refused. + tx_targets.send(Targets::default()).unwrap(); + let unavailable = client.iam().body(None).send().await.unwrap_err(); + if let ClientError::UnexpectedResponse(response) = unavailable { + assert_eq!(response.status(), 503); + } + + shutdown_proxy.cancel(); + server.close().await.expect("can't shutdown server"); +} + +/// The proxy serves the platform identity, here a local test PKI, and +/// the client accepts exactly the chains that verify against its +/// roots. +#[named] +#[test] +async fn client_tls_proxy_server() { + // Spin up a full server. + let log = test_logger(function_name!()); + let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log.clone()).await; + let api = api_description::().unwrap(); + let server = ServerBuilder::new(api, Arc::new(mgr), log.clone()) + .config(ConfigDropshot { + bind_address: local_addr(), + ..Default::default() + }) + .start() + .expect("failed to start server"); + + // Spin up a TLS proxy with a local platform identity. + // The test PKI numbers its nodes from 1. + let (_pki_dir, pki) = test_pki("sush-tls-"); + let resolve = ResolveSetting::Local { + priv_key: private_key_path(pki.clone(), &sprockets_auth_prefix(1)), + cert_chain: certlist_path(pki.clone(), &sprockets_auth_prefix(1)), + }; + let tls = platform_tls(&log, resolve).expect("can't build TLS config"); + let (_tx_targets, rx_targets) = watch::channel(Targets { + sleds: BTreeMap::from([(test_baseboard_id(), server.local_addr())]), + cubbies: Cubbies::new(), + }); + let rx_delegated = rx_targets.clone(); let shutdown_proxy = CancellationToken::new(); let proxy = ProxyServer::start( &log, local_addr(), - server.local_addr(), + Some(tls), + rx_targets, shutdown_proxy.clone(), ) .await - .expect("can't start proxy server"); - let proxy_addr = proxy.local_addr(); - assert_ne!(server_addr, proxy_addr); + .expect("can't start TLS proxy server"); + let url = format!("https://{}", proxy.local_addr()); - // Connect and authenticate to the server via the proxy. + // A client rooted in the same PKI authenticates and works. + let pem = read(cert_path(pki.clone(), &root_prefix())).unwrap(); + let roots = vec![Certificate::from_pem(&pem).unwrap()]; let signer = AuthzSigner::default(); - let client = Client::new(&format!("http://{proxy_addr}"), signer.clone()); + let client = Client::new_with_client(&url, tls_client(roots).unwrap(), signer.clone()); let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() else { panic!("expected error response") @@ -168,6 +343,122 @@ async fn client_proxy_server() { .into_inner(); assert_eq!(iam, identity, "who am I?"); + // A client rooted elsewhere refuses the handshake. + let (_other_dir, other) = test_pki("sush-tls-other-"); + let pem = read(cert_path(other.clone(), &root_prefix())).unwrap(); + let strangers = vec![Certificate::from_pem(&pem).unwrap()]; + let stranger = + Client::new_with_client(&url, tls_client(strangers).unwrap(), AuthzSigner::default()); + assert!(stranger.iam().body(None).send().await.is_err()); + + // A second proxy serves an ephemeral leaf the platform identity + // signed once, in the RoT's convention. The same roots accept it. + let signer_pem = read_to_string(private_key_path(pki.clone(), &sprockets_auth_prefix(1))) + .expect("can't read the platform key"); + let platform_key = + SigningKey::from_pkcs8_pem(&signer_pem).expect("can't parse the platform key"); + let chain_pem = read_to_string(certlist_path(pki.clone(), &sprockets_auth_prefix(1))) + .expect("can't read the platform chain"); + let platform = + Certificate::load_pem_chain(chain_pem.as_bytes()).expect("can't parse the platform chain"); + let leaf = EphemeralKey::new_delegated( + KeyType::Ed25519, + "CN=sush-proxy".parse().unwrap(), + platform[0].tbs_certificate.subject.clone(), + Validity::from_now(Duration::from_secs(600)).unwrap(), + AlgorithmIdentifierOwned { + oid: ID_ED_25519, + parameters: None, + }, + |tbs| Ok::<_, Infallible>(platform_key.sign(&Sha3_256::digest(tbs)).to_vec()), + ) + .expect("can't mint a delegated leaf"); + let key_path = pki.join("sush-proxy-key.pem"); + let chain_path = pki.join("sush-proxy-chain.pem"); + write(&key_path, leaf.private_key_pem().unwrap()).unwrap(); + let leaf_pem = pem_cert_chain(vec![leaf.cert().clone()]).unwrap(); + write(&chain_path, format!("{leaf_pem}{chain_pem}")).unwrap(); + + let rx_forged = rx_delegated.clone(); + let delegated = platform_tls( + &log, + ResolveSetting::Local { + priv_key: key_path, + cert_chain: chain_path, + }, + ) + .expect("can't build delegated TLS config"); + let proxy2 = ProxyServer::start( + &log, + local_addr(), + Some(delegated), + rx_delegated, + shutdown_proxy.clone(), + ) + .await + .expect("can't start delegated proxy server"); + let pem = read(cert_path(pki, &root_prefix())).unwrap(); + let roots = vec![Certificate::from_pem(&pem).unwrap()]; + let client2 = Client::new_with_client( + &format!("https://{}", proxy2.local_addr()), + tls_client(roots.clone()).unwrap(), + signer.clone(), + ); + let iam = client2 + .iam() + .body(None) + .send() + .await + .expect("can't authenticate via the delegated proxy") + .into_inner(); + assert_eq!(iam, identity, "who am I, again?"); + + // A leaf on a valid platform chain, but signed by the wrong key, + // refuses the handshake. + let forger_pem = read_to_string(private_key_path(other.clone(), &sprockets_auth_prefix(1))) + .expect("can't read the forger's key"); + let forger_key = SigningKey::from_pkcs8_pem(&forger_pem).expect("can't parse the forger's key"); + let forged = EphemeralKey::new_delegated( + KeyType::Ed25519, + "CN=sush-proxy".parse().unwrap(), + platform[0].tbs_certificate.subject.clone(), + Validity::from_now(Duration::from_secs(600)).unwrap(), + AlgorithmIdentifierOwned { + oid: ID_ED_25519, + parameters: None, + }, + |tbs| Ok::<_, Infallible>(forger_key.sign(&Sha3_256::digest(tbs)).to_vec()), + ) + .expect("can't mint a forged leaf"); + let forged_key_path = other.join("sush-proxy-key.pem"); + let forged_chain_path = other.join("sush-proxy-chain.pem"); + write(&forged_key_path, forged.private_key_pem().unwrap()).unwrap(); + let forged_pem = pem_cert_chain(vec![forged.cert().clone()]).unwrap(); + write(&forged_chain_path, format!("{forged_pem}{chain_pem}")).unwrap(); + let forged_tls = platform_tls( + &log, + ResolveSetting::Local { + priv_key: forged_key_path, + cert_chain: forged_chain_path, + }, + ) + .expect("can't build forged TLS config"); + let proxy3 = ProxyServer::start( + &log, + local_addr(), + Some(forged_tls), + rx_forged, + shutdown_proxy.clone(), + ) + .await + .expect("can't start forged proxy server"); + let client3 = Client::new_with_client( + &format!("https://{}", proxy3.local_addr()), + tls_client(roots).unwrap(), + signer.clone(), + ); + assert!(client3.iam().body(None).send().await.is_err()); + shutdown_proxy.cancel(); server.close().await.expect("can't shutdown server"); } @@ -214,7 +505,7 @@ async fn interactive_job() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log.clone()).await; let api = api_description::().unwrap(); - let server = ServerBuilder::new(api, mgr, log) + let server = ServerBuilder::new(api, Arc::new(mgr), log) .config(ConfigDropshot { bind_address: local_addr(), ..Default::default() @@ -243,7 +534,7 @@ async fn interactive_job() { assert_eq!(iam, identity, "who am I?"); // Start a session and run an interactive job. - let session = Session::new(SessionId::new()); + let session = Session::new(SessionId::random()); client .session_start() .session_id(session.session_id()) @@ -261,7 +552,7 @@ async fn interactive_job() { } = JobLimits::default(); client .job_start() - .job_id(&job_id) + .job_id(job_id) .max_cpu(max_cpu) .max_mem(max_mem) .max_fsize(max_fsize) @@ -276,7 +567,7 @@ async fn interactive_job() { // Attach to the job. let socket1 = client .job_attach() - .job_id(&job_id) + .job_id(job_id) .target(test_baseboard_id().to_string()) .send() .await @@ -305,7 +596,7 @@ async fn interactive_job() { // and playback. let socket2 = client .job_attach() - .job_id(&job_id) + .job_id(job_id) .target(test_baseboard_id().to_string()) .send() .await @@ -346,7 +637,7 @@ async fn interactive_job() { let guest_attach = async || { guest_client .job_attach() - .job_id(&job_id) + .job_id(job_id) .target(test_baseboard_id().to_string()) .send() .await @@ -466,7 +757,7 @@ async fn interactive_job() { // Stop the job. client .job_stop() - .job_id(&job_id) + .job_id(job_id) .wait(JobWait::Stop) .send() .await @@ -476,7 +767,7 @@ async fn interactive_job() { // from the read-only guest. let mut output = client .job_output() - .job_id(&job_id) + .job_id(job_id) .stream(JobOutputStream::Stdout) .target(test_baseboard_id().to_string()) .send() diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index 9eed68b..8fb18fa 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -13,31 +13,34 @@ use chrono::Utc; use function_name::named; use http_range_header::{EndPosition, StartPosition, SyntacticallyCorrectRange as Range}; use pwd::Passwd; -use rumors::Peer; use sled_hardware_types::BaseboardId; use slog::{Discard, Logger, o}; use tempfile::TempDir; -use tokio::fs::metadata; +use tokio::fs::{metadata, write}; +use tokio::sync::watch; use tokio::time::{sleep, timeout}; use tokio_util::sync::CancellationToken; use x509_cert::time::Validity; use sush_api::{JobStartParams, JobStopParams, JobWait}; use sush_client::context::Authz; -use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, RequestKey}; +use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, Nonce, RequestKey}; use sush_common::jobs::{ - Access, JobId, JobLimits, JobOutputState, JobOutputStream::*, JobStatus, ProcessError, Session, - SessionId, + Access, JobId, JobLimits, JobOutputState, JobOutputStream::*, JobStartRequest, JobStatus, + ProcessError, Session, SessionId, }; -use sush_common::keys::{EphemeralKey, KeyError, KeyId, KeyType, Signer as _}; +use sush_common::keys::{EphemeralKey, KeyError, KeyId, KeyType, Signer as _, pem_cert_chain}; +use sush_common::targets::{Cubbies, Target}; +use sush_server::gossip::isolated; use sush_server::io::BATCH_OUTPUT_BUFFER_SIZE; -use sush_server::messages::v0::{CertRequest, Message, Request, SessionRequest}; -use sush_server::output::JobOutputDir; -use sush_server::{JobError, JobManager}; +use sush_server::messages::v0::{CertRequest, IdentityRequest, Message, Request, SessionRequest}; +use sush_server::output::{JobOutputDir, OutputDirs}; +use sush_server::{JobError, JobManager, seed_gossip}; use crate::test_utils::{ - SignJobRequest as _, ephemeral_test_subject, fake_identity, manager_and_test_root, - manager_login, manager_test_root_and_peer, test_logger, + IntoBytes as _, SignJobRequest as _, ephemeral_test_root, ephemeral_test_subject, + fake_identity, manager_and_test_root, manager_login, manager_test_root_and_peer, no_cubbies, + test_baseboard_id, test_logger, }; use sush_server::executor::PathIsolation; @@ -101,11 +104,9 @@ async fn jobs() { let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let baseboard_id = mgr.own_baseboard(); let authn = fake_identity(&mut root).await; - let session_id = SessionId::new(); - let mut session = Session::new(session_id.clone()); - mgr.session_start(&authn, session_id.clone(), true) - .await - .unwrap(); + let session_id = SessionId::random(); + let mut session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); let job_id = session.next_job_id(); let job = root.sign_job_request(&job_id, "true", false).await; @@ -227,11 +228,9 @@ async fn job_stop() { let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let baseboard_id = mgr.own_baseboard(); let authn = fake_identity(&mut root).await; - let session_id = SessionId::new(); - let mut session = Session::new(session_id.clone()); - mgr.session_start(&authn, session_id.clone(), true) - .await - .unwrap(); + let session_id = SessionId::random(); + let mut session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); // Stopping a nonexistent job should mark it cancelled, and so succeed // immediately. @@ -252,8 +251,8 @@ async fn job_stop() { )); // Skip the cancelled job. - session.skip_job(job_id.clone()); - mgr.session_skip_job(&authn, session_id.clone(), job_id.clone()) + session.skip_job(job_id); + mgr.session_skip_job(&authn, session_id, job_id) .await .expect("should be able to skip cancelled job"); @@ -311,11 +310,9 @@ async fn cancel_queued_job() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let authn = fake_identity(&mut root).await; - let session_id = SessionId::new(); - let mut session = Session::new(session_id.clone()); - mgr.session_start(&authn, session_id.clone(), true) - .await - .unwrap(); + let session_id = SessionId::random(); + let mut session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); // Queue job A, which won't finish soon. let command_a = "sleep 10"; @@ -333,7 +330,12 @@ async fn cancel_queued_job() { .expect("should be able to start job A"); session.job_started(job_a.into_signed()); - // Queue job B ... + // Queue job B behind a hole in the job chain, so it cannot start + // before we cancel it: the executor only runs the job whose id the + // chain expects next, and it never sees this one. + let hole_id = session.next_job_id(); + let hole = root.sign_job_request(&hole_id, "true", false).await; + session.job_started(hole.into_signed()); let command_b = "false"; let job_id_b = session.next_job_id(); let job_b = root.sign_job_request(&job_id_b, command_b, false).await; @@ -386,11 +388,9 @@ async fn job_output_perms() { let dir_perms = metadata(&dir).await.unwrap().permissions(); let baseboard_id = mgr.own_baseboard(); let authn = fake_identity(&mut root).await; - let session_id = SessionId::new(); - let session = Session::new(session_id.clone()); - mgr.session_start(&authn, session_id.clone(), true) - .await - .unwrap(); + let session_id = SessionId::random(); + let session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); // Run a job with some output on both streams. let command = "echo -n foo && echo -n bar >&2"; @@ -415,7 +415,7 @@ async fn job_output_perms() { check_status_stopped(status, &job_id, Ok(0), Some(3), Some(3)); // Job output root should not be changed. - let out = JobOutputDir::new(dir.as_ref().to_owned()); + let out = JobOutputDir::fixed(dir.as_ref()); assert_eq!(metadata(out.root()).await.unwrap().permissions(), dir_perms); // Check output directory and file permissions. @@ -429,15 +429,340 @@ async fn job_output_perms() { #[named] #[tokio::test] -async fn shutdown() { +async fn cubby_targets() { + // A cubby target only matches through the rack's cubby map. let log = test_logger(function_name!()); - let (mut mgr, mut root, _dir, shutdown) = manager_and_test_root(log).await; + let dir = TempDir::with_prefix("sush-").unwrap(); + let mut root = ephemeral_test_root(); + let (cubbies, cubbies_rx) = watch::channel(Cubbies::new()); + let mgr = JobManager::with_root_certs( + log, + PathIsolation::InsecureDisable, + JobOutputDir::fixed(dir.path()), + test_baseboard_id(), + cubbies_rx, + isolated(seed_gossip()), + &[root.cert().to_owned()], + CancellationToken::new(), + ) + .await + .unwrap(); let authn = fake_identity(&mut root).await; - let session_id = SessionId::new(); - let session = Session::new(session_id.clone()); - mgr.session_start(&authn, session_id.clone(), true) + let session_id = SessionId::random(); + let mut session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); + let target: Target = "14".parse().unwrap(); + + // With the map empty, a cubby-targeted job records no status here. + // The all-target job behind it in the session's job chain proves + // it was processed, not merely still queued. + let skipped_id = session.next_job_id(); + let job = root + .sign_job_request_for(&skipped_id, "true", false, target.clone()) + .await; + mgr.job_start(&authn, job.clone().into_signed(), JobStartParams::default()) .await .unwrap(); + session.job_started(job.into_signed()); + let job_id = session.next_job_id(); + let job = root.sign_job_request(&job_id, "true", false).await; + mgr.job_start( + &authn, + job.clone().into_signed(), + JobStartParams { + wait: JobWait::Stop, + ..Default::default() + }, + ) + .await + .unwrap(); + session.job_started(job.into_signed()); + assert!(matches!( + mgr.job_status(&authn, &skipped_id).await.unwrap_err(), + JobError::JobNotFound(jid) if jid == skipped_id + )); + + // Name this baseboard in the map. The update lands whenever the + // state task gets to it, so probe with fresh jobs until one takes. + cubbies + .send(Cubbies::from([(14, test_baseboard_id())])) + .unwrap(); + loop { + let job_id = session.next_job_id(); + let job = root + .sign_job_request_for(&job_id, "true", false, target.clone()) + .await; + mgr.job_start(&authn, job.clone().into_signed(), JobStartParams::default()) + .await + .unwrap(); + session.job_started(job.into_signed()); + sleep(Duration::from_millis(50)).await; + if mgr.job_status(&authn, &job_id).await.is_ok() { + break; + } + } + + // Now cubby-targeted jobs run here. + let job_id = session.next_job_id(); + let job = root + .sign_job_request_for(&job_id, "true", false, target) + .await; + mgr.job_start( + &authn, + job.clone().into_signed(), + JobStartParams { + wait: JobWait::Stop, + ..Default::default() + }, + ) + .await + .unwrap(); + session.job_started(job.into_signed()); + let status = mgr.job_status(&authn, &job_id).await.unwrap()[mgr.own_baseboard()].clone(); + check_status_stopped(status, &job_id, Ok(0), Some(0), Some(0)); +} + +#[named] +#[tokio::test] +async fn root_certs_from_files() { + // The sled-agent embedding configures roots as paths, so the manager reads + // them itself. + let log = test_logger(function_name!()); + let dir = TempDir::with_prefix("sush-").unwrap(); + let mut root = ephemeral_test_root(); + let path = dir.path().join("root.pem"); + write(&path, pem_cert_chain(vec![root.cert().to_owned()]).unwrap()) + .await + .unwrap(); + let mgr = JobManager::new( + log, + PathIsolation::InsecureDisable, + JobOutputDir::fixed(dir.path()), + test_baseboard_id(), + no_cubbies(), + isolated(seed_gossip()), + &[path], + CancellationToken::new(), + ) + .await + .unwrap(); + + // A job signed by the configured root runs. + let authn = fake_identity(&mut root).await; + let session_id = SessionId::random(); + let session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); + let job_id = session.next_job_id(); + let job = root.sign_job_request(&job_id, "true", false).await; + mgr.job_start( + &authn, + job.into_signed(), + JobStartParams { + wait: JobWait::Stop, + ..Default::default() + }, + ) + .await + .unwrap(); + let status = mgr.job_status(&authn, &job_id).await.unwrap()[mgr.own_baseboard()].clone(); + check_status_stopped(status, &job_id, Ok(0), Some(0), Some(0)); +} + +#[named] +#[tokio::test] +async fn bad_root_cert_files() { + // A trust store we can't read is an error, not an empty trust store. + let log = test_logger(function_name!()); + let dir = TempDir::with_prefix("sush-").unwrap(); + let undecodable = dir.path().join("undecodable.pem"); + write(&undecodable, "not a certificate").await.unwrap(); + for path in [undecodable, dir.path().join("missing.pem")] { + assert!( + JobManager::new( + log.clone(), + PathIsolation::InsecureDisable, + JobOutputDir::fixed(dir.path()), + test_baseboard_id(), + no_cubbies(), + isolated(seed_gossip()), + &[path], + CancellationToken::new(), + ) + .await + .is_err() + ); + } +} + +#[named] +#[tokio::test] +async fn job_output_dir_moves() { + // A server may move its output base part way through its life: sled-agent + // records job output on a ramdisk until an encrypted dataset is mounted, + // then moves to it. Output recorded before a move must stay readable. + let log = test_logger(function_name!()); + let ramdisk = TempDir::with_prefix("sush-ramdisk-").unwrap(); + let encrypted = TempDir::with_prefix("sush-encrypted-").unwrap(); + let (tx_dirs, rx_dirs) = watch::channel(OutputDirs::new( + ramdisk.path(), + JobLimits::default().max_fsize, + )); + let mut root = ephemeral_test_root(); + let mgr = JobManager::with_root_certs( + log, + PathIsolation::InsecureDisable, + JobOutputDir::new(rx_dirs), + test_baseboard_id(), + no_cubbies(), + isolated(seed_gossip()), + &[root.cert().to_owned()], + CancellationToken::new(), + ) + .await + .unwrap(); + let baseboard_id = mgr.own_baseboard(); + let authn = fake_identity(&mut root).await; + let session_id = SessionId::random(); + let mut session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); + + // Record a job's output under the first base. + let first = session.next_job_id(); + let job = root.sign_job_request(&first, "echo -n foo", false).await; + mgr.job_start( + &authn, + job.clone().into_signed(), + JobStartParams { + wait: JobWait::Stop, + ..Default::default() + }, + ) + .await + .unwrap(); + session.job_started(job.clone().into_signed()); + assert!( + metadata(ramdisk.path().join("jobs").join(first.to_string())) + .await + .is_ok() + ); + + // Move to the second base. + tx_dirs.send_modify(|dirs| { + *dirs = dirs.moved_to(encrypted.path(), JobLimits::default().max_fsize) + }); + + // The first job's output is still readable, from the base it was written to. + assert_eq!( + mgr.job_output(&authn, &first, baseboard_id, Stdout, None) + .await + .unwrap() + .into_bytes() + .await, + b"foo".as_slice() + ); + + // New jobs are recorded under the new base. + let second = session.next_job_id(); + let job = root.sign_job_request(&second, "echo -n bar", false).await; + mgr.job_start( + &authn, + job.clone().into_signed(), + JobStartParams { + wait: JobWait::Stop, + ..Default::default() + }, + ) + .await + .unwrap(); + session.job_started(job.clone().into_signed()); + assert!( + metadata(encrypted.path().join("jobs").join(second.to_string())) + .await + .is_ok() + ); + assert_eq!( + mgr.job_output(&authn, &second, baseboard_id, Stdout, None) + .await + .unwrap() + .into_bytes() + .await, + b"bar".as_slice() + ); +} + +#[named] +#[tokio::test] +async fn universe_swap() { + // A universe migration resets the state machine. Sessions and history + // die with the old universe, and the manager keeps serving. + let log = test_logger(function_name!()); + let dir = TempDir::with_prefix("sush-").unwrap(); + let mut root = ephemeral_test_root(); + let (universe, universe_rx) = watch::channel(seed_gossip()); + let mgr = JobManager::with_root_certs( + log, + PathIsolation::InsecureDisable, + JobOutputDir::fixed(dir.path()), + test_baseboard_id(), + no_cubbies(), + universe_rx, + &[root.cert().to_owned()], + CancellationToken::new(), + ) + .await + .unwrap(); + let authn = fake_identity(&mut root).await; + + async fn run_job(mgr: &JobManager, root: &mut EphemeralKey, authn: &Identity) -> JobId { + let session_id = SessionId::random(); + let session = Session::new(session_id); + mgr.session_start(authn, session_id, true).await.unwrap(); + let job_id = session.next_job_id(); + let job = root.sign_job_request(&job_id, "true", false).await; + mgr.job_start( + authn, + job.into_signed(), + JobStartParams { + wait: JobWait::Stop, + ..Default::default() + }, + ) + .await + .unwrap(); + job_id + } + + // Run a job in the first universe. + let job_id = run_job(&mgr, &mut root, &authn).await; + assert!(mgr.job_status(&authn, &job_id).await.is_ok()); + + // Migrate. The session and the job's history are gone. + universe.send(seed_gossip()).unwrap(); + timeout(Duration::from_secs(30), async { + while mgr.session(&authn).is_some() { + sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("state reset"); + assert!(matches!( + mgr.job_status(&authn, &job_id).await, + Err(JobError::JobNotFound(_)) + )); + + // The manager still works in the new universe. + run_job(&mgr, &mut root, &authn).await; +} + +#[named] +#[tokio::test] +async fn shutdown() { + let log = test_logger(function_name!()); + let (mut mgr, mut root, _dir, shutdown) = manager_and_test_root(log).await; + let authn = fake_identity(&mut root).await; + let session_id = SessionId::random(); + let session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); let command = "sleep 30"; let job_id = session.next_job_id(); @@ -504,13 +829,14 @@ async fn cert_chain() { part_number: "test part".to_string(), serial_number: "0000".to_string(), }; - let gossip = Peer::seed().into_rumors(); + let gossip = isolated(seed_gossip()); let shutdown = CancellationToken::new(); - let mgr = JobManager::new( + let mgr = JobManager::with_root_certs( log, PathIsolation::InsecureDisable, - dir.path().to_owned(), + JobOutputDir::fixed(dir.path()), baseboard, + no_cubbies(), gossip, &roots, shutdown, @@ -545,8 +871,8 @@ async fn cert_chain() { ); // Start a job signed with the child. - let session_id = SessionId::new(); - let mut session = Session::new(session_id.clone()); + let session_id = SessionId::random(); + let mut session = Session::new(session_id); mgr.session_start(&authn, session_id, true).await.unwrap(); let job_id = session.next_job_id(); let job = child.sign_job_request(&job_id, "true", false).await; @@ -573,11 +899,9 @@ async fn attribution() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let authn = fake_identity(&mut root).await; - let session_id = SessionId::new(); - let mut session = Session::new(session_id.clone()); - mgr.session_start(&authn, session_id.clone(), true) - .await - .unwrap(); + let session_id = SessionId::random(); + let mut session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); assert_eq!( mgr.session(&authn).unwrap().started_by(), Some(&authn.key_id) @@ -686,8 +1010,8 @@ async fn revocation_tombstones() { )) .into() }; - for i in 0..200 { - peer.send(revoke(KeyId::from(format!("bogus-{i}")))); + for _ in 0..200 { + peer.send(revoke(KeyId::random())); } peer.send(revoke(doomed.key_id().clone())); @@ -786,6 +1110,53 @@ async fn cert_revoke() { .expect("tombstone never consumed the import"); } +/// A job runs only on the sleds its signed target names. Jobs naming +/// another baseboard, or a cubby while no mapping is known, are +/// processed but never run here, and the causal chain moves on. +#[named] +#[tokio::test] +async fn job_targets() { + let log = test_logger(function_name!()); + let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; + let authn = fake_identity(&mut root).await; + let session_id = SessionId::random(); + let mut session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); + + let mut start = async |command: &str, target: &str| { + let job_id = session.next_job_id(); + let request = JobStartRequest::new(job_id, command, false, target.parse().unwrap()); + let job = root + .sign(request) + .await + .unwrap() + .verify_with_cert(root.cert()) + .unwrap(); + mgr.job_start(&authn, job.clone().into_signed(), JobStartParams::default()) + .await + .unwrap(); + session.job_started(job.into_signed()); + job_id + }; + + let elsewhere = start("true", "913-0000019:BRM00000000").await; + let by_cubby = start("true", "14").await; + let here = start("true", &test_baseboard_id().to_string()).await; + + // The targeted job runs, which proves the untargeted ones were + // already processed and skipped. + timeout(Duration::from_secs(30), mgr.wait_for_job_status(&here)) + .await + .expect("timed out waiting for targeted job") + .unwrap(); + for skipped in [elsewhere, by_cubby] { + assert!(matches!( + mgr.job_status(&authn, &skipped).await, + Err(JobError::JobNotFound(_)), + )); + } +} + /// Identity revocation: live bound credentials die and the key may /// not log back in. #[named] @@ -801,7 +1172,7 @@ async fn iam_revoke() { .await .unwrap(); - mgr.iam_revoke(&authn, key_id).await.unwrap(); + mgr.iam_revoke(&authn, key_id, true).await.unwrap(); let header = authz.header("GET", "/sessions"); assert!( mgr.iam(Some(header), None, ("GET", "/sessions")) @@ -811,6 +1182,75 @@ async fn iam_revoke() { assert!(manager_login(&mgr, &mut root).await.is_err()); } +/// A login gossips as evidence every sled verifies for itself: real +/// evidence authorizes bound requests with no local login, and +/// fabricated evidence registers nothing. +#[named] +#[tokio::test] +async fn gossiped_identities() { + let log = test_logger(function_name!()); + let (mgr, mut root, peer, _dir, _shutdown) = manager_test_root_and_peer(log).await; + let mut liar = ephemeral_test_root(); + let root_pk = root.ssh_public_key(); + let root_key_id = root_pk.key_id().unwrap(); + + // A liar claims root's key with evidence signed by their own. + let bogus_key = RequestKey::new(); + let bogus = ChallengeResponse::new(Challenge::new(Nonce::random()), bogus_key.verifier()); + let signed_by_liar = liar.sign(bogus).await.unwrap(); + let bogus_verified = signed_by_liar + .clone() + .verify_with_ssh_public_key(&liar.ssh_public_key()) + .unwrap(); + let mut bogus_credentials = Credentials::new(bogus_verified); + bogus_credentials.key_id = root_key_id.clone(); + let bogus_authz = Authz::new(bogus_credentials, bogus_key); + peer.send( + Message::Request(Request::identity( + root_key_id.clone(), + IdentityRequest::Login(root_pk.clone(), signed_by_liar), + )) + .into(), + ); + + // Real evidence authorizes here without ever logging in here. + let request_key = RequestKey::new(); + let response = ChallengeResponse::new(Challenge::new(Nonce::random()), request_key.verifier()); + let signed = root.sign(response).await.unwrap(); + let verified = signed.clone().verify_with_ssh_public_key(&root_pk).unwrap(); + let mut credentials = Credentials::new(verified); + credentials.key_id = root_key_id.clone(); + peer.send( + Message::Request(Request::identity( + root_key_id.clone(), + IdentityRequest::Login(root_pk.clone(), signed), + )) + .into(), + ); + let authz = Authz::new(credentials, request_key); + let authn = timeout(Duration::from_secs(30), async { + loop { + let header = authz.header("GET", "/sessions"); + match mgr.iam(Some(header), None, ("GET", "/sessions")).await { + Ok(authn) => break authn, + Err(_) => sleep(Duration::from_millis(50)).await, + } + } + }) + .await + .expect("gossiped login never registered"); + assert_eq!(authn.key_id, root_key_id); + + // The fabricated login was processed before the real one on the + // same handle, and registered nothing. + let header = bogus_authz.header("GET", "/sessions"); + assert!( + mgr.iam(Some(header), None, ("GET", "/sessions")) + .await + .is_err() + ); +} + /// Attach access: strangers are refused, the starter grants and /// withdraws guest access mid-session, only the starter may grant, and /// grants from non-starters over gossip are ignored. @@ -825,11 +1265,9 @@ async fn attach_grants() { EphemeralKey::new_root(KeyType::P256, ephemeral_test_subject(), validity).unwrap(); let guest = fake_identity(&mut guest_key).await; - let session_id = SessionId::new(); - let session = Session::new(session_id.clone()); - mgr.session_start(&owner, session_id.clone(), true) - .await - .unwrap(); + let session_id = SessionId::random(); + let session = Session::new(session_id); + mgr.session_start(&owner, session_id, true).await.unwrap(); let job_id = session.next_job_id(); let job = root.sign_job_request(&job_id, "sleep 10", false).await; mgr.job_start( @@ -856,13 +1294,8 @@ async fn attach_grants() { Err(JobError::AttachDenied) )); assert!(matches!( - mgr.session_allow_attach( - &guest, - session_id.clone(), - owner.key_id.clone(), - Access::ReadOnly - ) - .await, + mgr.session_allow_attach(&guest, session_id, owner.key_id.clone(), Access::ReadOnly) + .await, Err(JobError::NotSessionStarter) )); @@ -885,25 +1318,15 @@ async fn attach_grants() { .await .expect("grant never took effect") }; - mgr.session_allow_attach( - &owner, - session_id.clone(), - guest.key_id.clone(), - Access::ReadOnly, - ) - .await - .unwrap(); + mgr.session_allow_attach(&owner, session_id, guest.key_id.clone(), Access::ReadOnly) + .await + .unwrap(); granted(guest.clone(), Some(Access::ReadOnly)).await; - mgr.session_allow_attach( - &owner, - session_id.clone(), - guest.key_id.clone(), - Access::ReadWrite, - ) - .await - .unwrap(); + mgr.session_allow_attach(&owner, session_id, guest.key_id.clone(), Access::ReadWrite) + .await + .unwrap(); granted(guest.clone(), Some(Access::ReadWrite)).await; - mgr.session_deny_attach(&owner, session_id.clone(), guest.key_id.clone()) + mgr.session_deny_attach(&owner, session_id, guest.key_id.clone()) .await .unwrap(); granted(guest.clone(), None).await; @@ -917,11 +1340,7 @@ async fn attach_grants() { peer.send( Message::Request(Request::session( guest.key_id.clone(), - SessionRequest::AllowAttach( - session_id.clone(), - guest.key_id.clone(), - Access::ReadWrite, - ), + SessionRequest::AllowAttach(session_id, guest.key_id.clone(), Access::ReadWrite), )) .into(), ); @@ -978,15 +1397,16 @@ async fn hostile_imports_cannot_displace() { part_number: "test part".to_string(), serial_number: "0000".to_string(), }; - let gossip = Peer::seed().into_rumors(); - let peer = gossip.clone(); + let seed = seed_gossip(); + let peer = seed.clone(); let shutdown = CancellationToken::new(); - let mgr = JobManager::new( + let mgr = JobManager::with_root_certs( log, PathIsolation::InsecureDisable, - dir.path().to_owned(), + JobOutputDir::fixed(dir.path()), baseboard, - gossip, + no_cubbies(), + isolated(seed), from_ref(&root_cert), shutdown, ) @@ -1122,15 +1542,16 @@ async fn homonym_issuer_resolves_to_true_parent() { part_number: "test part".to_string(), serial_number: "0000".to_string(), }; - let gossip = Peer::seed().into_rumors(); - let peer = gossip.clone(); + let seed = seed_gossip(); + let peer = seed.clone(); let shutdown = CancellationToken::new(); - let mgr = JobManager::new( + let mgr = JobManager::with_root_certs( log, PathIsolation::InsecureDisable, - dir.path().to_owned(), + JobOutputDir::fixed(dir.path()), baseboard, - gossip, + no_cubbies(), + isolated(seed), from_ref(&root_cert), shutdown, ) @@ -1190,11 +1611,9 @@ async fn too_much_cpu() { let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let baseboard_id = mgr.own_baseboard(); let authn = fake_identity(&mut root).await; - let session_id = SessionId::new(); - let session = Session::new(session_id.clone()); - mgr.session_start(&authn, session_id.clone(), true) - .await - .unwrap(); + let session_id = SessionId::random(); + let session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); let job_id = session.next_job_id(); let command = "openssl speed sha1"; let job = root.sign_job_request(&job_id, command, false).await; @@ -1271,11 +1690,9 @@ async fn too_much_output() { let log = test_logger(function_name!()); let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let authn = fake_identity(&mut root).await; - let session_id = SessionId::new(); - let mut session = Session::new(session_id.clone()); - mgr.session_start(&authn, session_id.clone(), true) - .await - .unwrap(); + let session_id = SessionId::random(); + let mut session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); let job_id = session.next_job_id(); let command = "yes"; @@ -1363,11 +1780,9 @@ async fn output_ranges() { let (mgr, mut root, _dir, _shutdown) = manager_and_test_root(log).await; let baseboard_id = mgr.own_baseboard(); let authn = fake_identity(&mut root).await; - let session_id = SessionId::new(); - let session = Session::new(session_id.clone()); - mgr.session_start(&authn, session_id.clone(), true) - .await - .unwrap(); + let session_id = SessionId::random(); + let session = Session::new(session_id); + mgr.session_start(&authn, session_id, true).await.unwrap(); let job_id = session.next_job_id(); // Read some random bytes. diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index d6a3a4d..7f7a87e 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -7,13 +7,17 @@ use std::sync::OnceLock; use std::time::Duration; +use bytes::Bytes; +use camino::Utf8PathBuf; use chrono::Utc; +use futures::TryStreamExt as _; use rand_core::{OsRng, RngCore as _}; -use rumors::Peer; use sled_hardware_types::BaseboardId; use slog::{Drain as _, Logger, o}; use slog_term::{FullFormat, PlainSyncDecorator, TestStdoutWriter}; +use sprockets_tls_test_utils::{OutputFileExistsBehavior, generate_config}; use tempfile::TempDir; +use tokio::sync::watch; use tokio_util::sync::CancellationToken; use x509_cert::name::Name; use x509_cert::time::Validity; @@ -21,12 +25,15 @@ use x509_cert::time::Validity; use sush_client::context::Authz; use sush_client::{Client, ResponseValue}; use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, Nonce, RequestKey}; -use sush_common::codephrases::generate_id; +use sush_common::codephrases::Codephrase; use sush_common::jobs::{JobId, JobStartRequest, VerifiedJob}; use sush_common::keys::{EphemeralKey, KeyType, Signer}; +use sush_common::targets::{Cubbies, Target}; use sush_server::executor::PathIsolation; +use sush_server::gossip::isolated; +use sush_server::output::{JobOutputDir, JobOutputFileStream}; use sush_server::state::GossipNetwork; -use sush_server::{JobError, JobManager}; +use sush_server::{JobError, JobManager, seed_gossip}; static TEST_BASEBOARD_ID: OnceLock = OnceLock::new(); @@ -39,6 +46,24 @@ pub fn test_baseboard_id() -> BaseboardId { .clone() } +/// Collect a job output stream into memory. Convenient for tests, which know +/// their output is small; the server deliberately streams instead. +#[allow(async_fn_in_trait)] +pub trait IntoBytes { + async fn into_bytes(self) -> Vec; +} + +impl IntoBytes for JobOutputFileStream { + async fn into_bytes(self) -> Vec { + self.try_collect::>() + .await + .unwrap() + .into_iter() + .flatten() + .collect() + } +} + #[allow(async_fn_in_trait)] pub trait SignJobRequest { async fn sign_job_request>( @@ -46,20 +71,33 @@ pub trait SignJobRequest { job_id: &JobId, command: S, interactive: bool, + ) -> VerifiedJob { + self.sign_job_request_for(job_id, command, interactive, Target::All) + .await + } + + async fn sign_job_request_for>( + &mut self, + job_id: &JobId, + command: S, + interactive: bool, + target: Target, ) -> VerifiedJob; } impl SignJobRequest for EphemeralKey { - async fn sign_job_request>( + async fn sign_job_request_for>( &mut self, job_id: &JobId, command: S, interactive: bool, + target: Target, ) -> VerifiedJob { self.sign(JobStartRequest::new( job_id.to_owned(), command, interactive, + target, )) .await .expect("failed to sign job") @@ -72,7 +110,7 @@ impl SignJobRequest for EphemeralKey { pub fn ephemeral_test_subject() -> Name { let mut buf = [0; 8]; OsRng.fill_bytes(&mut buf); - let id = generate_id(); + let id = Codephrase::random().truncate(); format!("CN=Ephemeral Test Key {id},O=Oxide Computer Company,C=US") .parse() .unwrap() @@ -93,8 +131,21 @@ pub fn test_logger(test_name: &'static str) -> Logger { Logger::root(drain, o!("test" => test_name)) } +/// A one-node local PKI in a fresh temp dir, standing in for the +/// platform identity a real sled resolves from its RoT. +pub fn test_pki(prefix: &'static str) -> (TempDir, Utf8PathBuf) { + let tmp = TempDir::with_prefix(prefix).unwrap(); + let dir = Utf8PathBuf::from_path_buf(tmp.path().to_owned()).unwrap(); + let behavior = OutputFileExistsBehavior::Overwrite; + let doc = generate_config(1); + doc.write_key_pairs(dir.clone(), behavior).unwrap(); + doc.write_certificates(dir.clone(), behavior).unwrap(); + doc.write_certificate_lists(dir.clone(), behavior).unwrap(); + (tmp, dir) +} + pub async fn fake_identity(key: &mut EphemeralKey) -> Identity { - let nonce = Nonce::generate(); + let nonce = Nonce::random(); let challenge = Challenge::new(nonce.clone()); let response = ChallengeResponse::new(challenge, RequestKey::new().verifier()); let signed = key.sign(response).await.unwrap(); @@ -146,15 +197,17 @@ pub async fn manager_test_root_and_peer( CancellationToken, ) { let dir = TempDir::with_prefix("sush-").unwrap(); - let gossip = Peer::seed().into_rumors(); - let peer = gossip.clone(); + let seed = seed_gossip(); + let peer = seed.clone(); + let gossip = isolated(seed); let shutdown = CancellationToken::new(); let root = ephemeral_test_root(); - let mgr = JobManager::new( + let mgr = JobManager::with_root_certs( log, PathIsolation::InsecureDisable, - dir.path().to_owned(), + JobOutputDir::fixed(dir.path()), test_baseboard_id(), + no_cubbies(), gossip, &[root.cert().to_owned()], shutdown.clone(), @@ -164,6 +217,11 @@ pub async fn manager_test_root_and_peer( (mgr, root, peer, dir, shutdown) } +/// A cubby map that will never be known. +pub fn no_cubbies() -> watch::Receiver { + watch::channel(Cubbies::new()).1 +} + pub async fn authz( client: &Client, response: ResponseValue,