From bf58437d584feb053e6ed0bceff477dc8f23eeaa Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Thu, 6 Aug 2026 20:30:17 +0000 Subject: [PATCH 01/43] Bump dropshot to 0.17 --- Cargo.toml | 2 +- server/src/main.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index db34c69..0d34593 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ bytesize = "2" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive", "env", "wrap_help"] } crypto-bigint = "0.5" -dropshot = "0.16" +dropshot = "0.17" ed25519-dalek = { version = "2", features = ["rand_core"] } function_name = "0.3" futures = "0.3" diff --git a/server/src/main.rs b/server/src/main.rs index 8df2b3c..45fa188 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -118,6 +118,7 @@ async fn main() -> Result<(), String> { 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}"))?; From 462b8622848006b1ec286b782ab26824f60a6cfb Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Thu, 6 Aug 2026 20:34:07 +0000 Subject: [PATCH 02/43] Name the single-peer gossip network --- server/src/lib.rs | 1 + server/src/main.rs | 4 ++-- server/src/state.rs | 12 +++++++++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/server/src/lib.rs b/server/src/lib.rs index 8a1bd98..a17d99a 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -25,3 +25,4 @@ pub use error::JobError; pub use manager::JobManager; pub use proxy::ProxyServer; pub use server::ApiServer; +pub use state::seed_gossip; diff --git a/server/src/main.rs b/server/src/main.rs index 45fa188..87787d2 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -9,7 +9,6 @@ use std::path::PathBuf; 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::{select, spawn}; @@ -20,6 +19,7 @@ use x509_cert::der::DecodePem as _; use sush_api::sush_api_mod::api_description; use sush_server::executor::PathIsolation; use sush_server::manager::JobManager; +use sush_server::seed_gossip; use sush_server::server::ApiServer; const DEFAULT_ADDRESS: &str = "0.0.0.0:44444"; @@ -89,7 +89,7 @@ async fn main() -> Result<(), String> { }; // TODO: get/seed Rumors network - let gossip = Peer::seed().into_rumors(); + let gossip = seed_gossip(); #[cfg(feature = "test-support")] let roots = overridable_root_certs(&override_root_certs)?; diff --git a/server/src/state.rs b/server/src/state.rs index 1c2631f..7147620 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; @@ -724,6 +724,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 {} From 2f12697b73ab9cc9a4f6f1cb6e9b2debd5edafa1 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Thu, 6 Aug 2026 20:36:25 +0000 Subject: [PATCH 03/43] Share the job manager with an Arc --- server/src/main.rs | 3 ++- server/src/server.rs | 4 +++- tests/src/integration_tests.rs | 7 ++++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index 87787d2..137b64e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -6,6 +6,7 @@ use std::net::SocketAddr; use std::path::PathBuf; +use std::sync::Arc; use clap::Parser; use dropshot::{ConfigDropshot, ConfigLogging, ConfigLoggingLevel, HandlerTaskMode, ServerBuilder}; @@ -112,7 +113,7 @@ 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, diff --git a/server/src/server.rs b/server/src/server.rs index b646498..4e718ff 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, @@ -48,7 +50,7 @@ fn request_line(request: &dropshot::RequestInfo) -> (&str, &str) { } impl SushApi for ApiServer { - type Context = JobManager; + type Context = Arc; // Certificate management. diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index c6a6ad9..0e16e3d 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -5,6 +5,7 @@ //! Oxide Support Shell integration tests. use std::net::SocketAddr; +use std::sync::Arc; use std::time::Duration; use bytes::{Bytes, BytesMut}; @@ -43,7 +44,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() @@ -127,7 +128,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() @@ -214,7 +215,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() From 4b13e2782f1b10cefd766ced0a13802cc2557b23 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Thu, 6 Aug 2026 20:56:00 +0000 Subject: [PATCH 04/43] Let the job output directory move at runtime --- server/src/executor.rs | 21 ++++-- server/src/main.rs | 3 +- server/src/manager.rs | 4 +- server/src/output.rs | 142 ++++++++++++++++++++++++++++++++----- tests/src/manager_tests.rs | 111 +++++++++++++++++++++++++++-- tests/src/test_utils.rs | 3 +- 6 files changed, 250 insertions(+), 34 deletions(-) diff --git a/server/src/executor.rs b/server/src/executor.rs index 419dccb..1e96289 100644 --- a/server/src/executor.rs +++ b/server/src/executor.rs @@ -185,7 +185,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 +193,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 +223,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 +232,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) diff --git a/server/src/main.rs b/server/src/main.rs index 137b64e..9ac60c2 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -20,6 +20,7 @@ use x509_cert::der::DecodePem as _; use sush_api::sush_api_mod::api_description; use sush_server::executor::PathIsolation; use sush_server::manager::JobManager; +use sush_server::output::JobOutputDir; use sush_server::seed_gossip; use sush_server::server::ApiServer; @@ -101,7 +102,7 @@ async fn main() -> Result<(), String> { let mut mgr = JobManager::new( log.clone(), path_isolation, - directory, + JobOutputDir::fixed(directory), baseboard, gossip, &roots, diff --git a/server/src/manager.rs b/server/src/manager.rs index 928ec98..b2343cb 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -6,7 +6,6 @@ //! state machine. Does not manage jobs directly. use std::num::NonZeroUsize; -use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -97,13 +96,12 @@ impl JobManager { pub async fn new( log: Logger, path_isolation: PathIsolation, - output_dir: PathBuf, + output_dir: JobOutputDir, own_baseboard: BaseboardId, rumors: GossipNetwork, 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( diff --git a/server/src/output.rs b/server/src/output.rs index ab0c354..3e72162 100644 --- a/server/src/output.rs +++ b/server/src/output.rs @@ -4,7 +4,8 @@ //! 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; @@ -17,16 +18,19 @@ use http_range_header::{EndPosition, StartPosition, SyntacticallyCorrectRange as 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); @@ -74,33 +78,133 @@ 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( @@ -122,8 +226,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/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index 9eed68b..f46a1f3 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -18,6 +18,7 @@ use sled_hardware_types::BaseboardId; use slog::{Discard, Logger, o}; use tempfile::TempDir; use tokio::fs::metadata; +use tokio::sync::watch; use tokio::time::{sleep, timeout}; use tokio_util::sync::CancellationToken; use x509_cert::time::Validity; @@ -32,12 +33,13 @@ use sush_common::jobs::{ use sush_common::keys::{EphemeralKey, KeyError, KeyId, KeyType, Signer as _}; 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::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, + SignJobRequest as _, ephemeral_test_root, ephemeral_test_subject, fake_identity, + manager_and_test_root, manager_login, manager_test_root_and_peer, test_baseboard_id, + test_logger, }; use sush_server::executor::PathIsolation; @@ -415,7 +417,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. @@ -427,6 +429,103 @@ async fn job_output_perms() { assert_eq!(mode(&out.job_output_path(&job_id, Stderr)).await, 0o600); } +#[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::new( + log, + PathIsolation::InsecureDisable, + JobOutputDir::new(rx_dirs), + test_baseboard_id(), + 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::new(); + let mut session = Session::new(session_id.clone()); + mgr.session_start(&authn, session_id.clone(), 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 shutdown() { @@ -509,7 +608,7 @@ async fn cert_chain() { let mgr = JobManager::new( log, PathIsolation::InsecureDisable, - dir.path().to_owned(), + JobOutputDir::fixed(dir.path()), baseboard, gossip, &roots, diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index d6a3a4d..17fa5f4 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -25,6 +25,7 @@ use sush_common::codephrases::generate_id; use sush_common::jobs::{JobId, JobStartRequest, VerifiedJob}; use sush_common::keys::{EphemeralKey, KeyType, Signer}; use sush_server::executor::PathIsolation; +use sush_server::output::JobOutputDir; use sush_server::state::GossipNetwork; use sush_server::{JobError, JobManager}; @@ -153,7 +154,7 @@ pub async fn manager_test_root_and_peer( let mgr = JobManager::new( log, PathIsolation::InsecureDisable, - dir.path().to_owned(), + JobOutputDir::fixed(dir.path()), test_baseboard_id(), gossip, &[root.cert().to_owned()], From a7309361adbf2393d62e4d281525297c5b14ae02 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Thu, 6 Aug 2026 20:59:27 +0000 Subject: [PATCH 05/43] Don't panic on a bad root certificate --- server/src/manager.rs | 2 +- server/src/state.rs | 32 +++++++++++++++++++++----------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/server/src/manager.rs b/server/src/manager.rs index b2343cb..1ad1106 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -113,7 +113,7 @@ impl JobManager { rumors, roots, shutdown, - ); + )?; Ok(Self { log: log.new(o!("component" => "job manager")), nonces: Arc::new(Mutex::new(LruCache::new(MAX_OUTSTANDING_NONCES))), diff --git a/server/src/state.rs b/server/src/state.rs index 7147620..1a4d6e6 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -284,16 +284,22 @@ pub struct State { } 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, @@ -307,9 +313,13 @@ impl State { }; 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 { @@ -753,12 +763,12 @@ impl StateManager { rumors: GossipNetwork, 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 (tx_state, rx_state) = watch::channel(State::new(own_baseboard.clone(), roots)?); // We process messages in causal order, so that we can rely on // things like "the session stop happens after its corresponding @@ -779,7 +789,7 @@ impl StateManager { // We will drop this once we want to drain the remaining messages. let mut rumors = Some(rumors); - ( + Ok(( rx_state, spawn(async move { info!(log, "managing state"); @@ -864,7 +874,7 @@ impl StateManager { } } }), - ) + )) } } From 004b01e171588c45d8c04d291e63e3cf77f6c6e0 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Thu, 6 Aug 2026 21:05:35 +0000 Subject: [PATCH 06/43] Read root certificates from paths --- server/src/lib.rs | 2 +- server/src/main.rs | 20 +++++----- server/src/manager.rs | 38 ++++++++++++++++++ tests/src/manager_tests.rs | 80 ++++++++++++++++++++++++++++++++++++-- tests/src/test_utils.rs | 2 +- 5 files changed, 125 insertions(+), 17 deletions(-) diff --git a/server/src/lib.rs b/server/src/lib.rs index a17d99a..5f840a5 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -22,7 +22,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/main.rs b/server/src/main.rs index 9ac60c2..36937ef 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -21,7 +21,7 @@ use sush_api::sush_api_mod::api_description; use sush_server::executor::PathIsolation; use sush_server::manager::JobManager; use sush_server::output::JobOutputDir; -use sush_server::seed_gossip; +use sush_server::{read_root_certs, seed_gossip}; use sush_server::server::ApiServer; const DEFAULT_ADDRESS: &str = "0.0.0.0:44444"; @@ -94,12 +94,12 @@ async fn main() -> Result<(), String> { let gossip = 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 mut mgr = JobManager::with_root_certs( log.clone(), path_isolation, JobOutputDir::fixed(directory), @@ -144,17 +144,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 1ad1106..07fa369 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -6,6 +6,7 @@ //! state machine. Does not manage jobs directly. use std::num::NonZeroUsize; +use std::path::Path; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -14,12 +15,14 @@ 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::{ @@ -93,7 +96,31 @@ pub struct JobManager { } impl JobManager { + /// Trust the root certificates in `roots`, one PEM-encoded certificate + /// per file. pub async fn new( + log: Logger, + path_isolation: PathIsolation, + output_dir: JobOutputDir, + own_baseboard: BaseboardId, + rumors: GossipNetwork, + roots: &[impl AsRef], + shutdown: CancellationToken, + ) -> Result { + let roots = read_root_certs(roots).await?; + Self::with_root_certs( + log, + path_isolation, + output_dir, + own_baseboard, + rumors, + &roots, + shutdown, + ) + .await + } + + pub async fn with_root_certs( log: Logger, path_isolation: PathIsolation, output_dir: JobOutputDir, @@ -604,4 +631,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/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index f46a1f3..e233c84 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -17,7 +17,7 @@ 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; @@ -30,7 +30,7 @@ use sush_common::jobs::{ Access, JobId, JobLimits, JobOutputState, JobOutputStream::*, 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_server::io::BATCH_OUTPUT_BUFFER_SIZE; use sush_server::messages::v0::{CertRequest, Message, Request, SessionRequest}; use sush_server::output::{JobOutputDir, OutputDirs}; @@ -429,6 +429,78 @@ async fn job_output_perms() { assert_eq!(mode(&out.job_output_path(&job_id, Stderr)).await, 0o600); } +#[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(), + 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::new(); + let session = Session::new(session_id.clone()); + mgr.session_start(&authn, session_id.clone(), 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(), + seed_gossip(), + &[path], + CancellationToken::new(), + ) + .await + .is_err() + ); + } +} + #[named] #[tokio::test] async fn job_output_dir_moves() { @@ -443,7 +515,7 @@ async fn job_output_dir_moves() { JobLimits::default().max_fsize, )); let mut root = ephemeral_test_root(); - let mgr = JobManager::new( + let mgr = JobManager::with_root_certs( log, PathIsolation::InsecureDisable, JobOutputDir::new(rx_dirs), @@ -605,7 +677,7 @@ async fn cert_chain() { }; let gossip = Peer::seed().into_rumors(); let shutdown = CancellationToken::new(); - let mgr = JobManager::new( + let mgr = JobManager::with_root_certs( log, PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index 17fa5f4..25d9bce 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -151,7 +151,7 @@ pub async fn manager_test_root_and_peer( let peer = gossip.clone(); let shutdown = CancellationToken::new(); let root = ephemeral_test_root(); - let mgr = JobManager::new( + let mgr = JobManager::with_root_certs( log, PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), From 63ab9761ab6e29c554e9af170193c540ffc74502 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Thu, 6 Aug 2026 22:09:03 +0000 Subject: [PATCH 07/43] Keep test-support out of the embedded server --- server/Cargo.toml | 1 + server/src/lib.rs | 3 +++ server/src/manager.rs | 5 ++--- server/src/output.rs | 13 +------------ tests/src/manager_tests.rs | 6 +++--- tests/src/test_utils.rs | 22 +++++++++++++++++++++- 6 files changed, 31 insertions(+), 19 deletions(-) diff --git a/server/Cargo.toml b/server/Cargo.toml index 8d4b2bd..fb45435 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -12,6 +12,7 @@ name = "sush-server" path = "src/main.rs" [features] +embedded = [] test-support = [] [dependencies] diff --git a/server/src/lib.rs b/server/src/lib.rs index 5f840a5..256c523 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -7,6 +7,9 @@ #[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 history; diff --git a/server/src/manager.rs b/server/src/manager.rs index 07fa369..403c3bc 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -411,9 +411,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(); diff --git a/server/src/output.rs b/server/src/output.rs index 3e72162..b8cf96e 100644 --- a/server/src/output.rs +++ b/server/src/output.rs @@ -11,9 +11,8 @@ 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; @@ -58,16 +57,6 @@ impl JobOutputFileStream { 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 { diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index e233c84..9665340 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -37,9 +37,9 @@ use sush_server::output::{JobOutputDir, OutputDirs}; use sush_server::{JobError, JobManager, seed_gossip}; use crate::test_utils::{ - SignJobRequest as _, ephemeral_test_root, ephemeral_test_subject, fake_identity, - manager_and_test_root, manager_login, manager_test_root_and_peer, test_baseboard_id, - 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, + test_baseboard_id, test_logger, }; use sush_server::executor::PathIsolation; diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index 25d9bce..fab0b60 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -7,7 +7,9 @@ use std::sync::OnceLock; use std::time::Duration; +use bytes::Bytes; use chrono::Utc; +use futures::TryStreamExt as _; use rand_core::{OsRng, RngCore as _}; use rumors::Peer; use sled_hardware_types::BaseboardId; @@ -25,7 +27,7 @@ use sush_common::codephrases::generate_id; use sush_common::jobs::{JobId, JobStartRequest, VerifiedJob}; use sush_common::keys::{EphemeralKey, KeyType, Signer}; use sush_server::executor::PathIsolation; -use sush_server::output::JobOutputDir; +use sush_server::output::{JobOutputDir, JobOutputFileStream}; use sush_server::state::GossipNetwork; use sush_server::{JobError, JobManager}; @@ -40,6 +42,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>( From a85afc169346110d72248740b79a733d481b32ae Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Fri, 7 Aug 2026 02:36:54 +0000 Subject: [PATCH 08/43] Add gossip over sprockets --- .github/workflows/test.yml | 4 +- Cargo.lock | 1722 +++++++++++++++++++++++++++-------- Cargo.toml | 25 +- rust-toolchain.toml | 2 +- server/Cargo.toml | 5 + server/src/gossip.rs | 364 ++++++++ server/src/lib.rs | 2 + server/src/link.rs | 338 +++++++ server/src/main.rs | 5 +- server/src/manager.rs | 8 +- server/src/output.rs | 4 +- server/src/state.rs | 60 +- server/tests/common/mod.rs | 174 ++++ server/tests/distributed.rs | 174 ++++ server/tests/gossip.rs | 174 ++++ server/tests/link.rs | 139 +++ tests/src/manager_tests.rs | 93 +- tests/src/test_utils.rs | 9 +- 18 files changed, 2858 insertions(+), 444 deletions(-) create mode 100644 server/src/gossip.rs create mode 100644 server/src/link.rs create mode 100644 server/tests/common/mod.rs create mode 100644 server/tests/distributed.rs create mode 100644 server/tests/gossip.rs create mode 100644 server/tests/link.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f292b4..2f25094 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 @@ -55,7 +55,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..8426daa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,33 @@ # 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" @@ -82,21 +109,6 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" - [[package]] name = "array-init" version = "0.0.4" @@ -118,6 +130,18 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[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" version = "1.1.1" @@ -126,7 +150,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -148,7 +172,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -159,7 +183,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -177,12 +201,113 @@ 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.18", +] + +[[package]] +name = "attest-data" +version = "0.5.0" +source = "git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd#6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd" +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.18", +] + +[[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", + "rats-corim", + "serde_json", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[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 = "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" @@ -216,12 +341,15 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[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", + "bytes", + "dashu-int", + "dsi-bitstream", + "static_assertions", + "suanpan", "thiserror 2.0.18", ] @@ -242,7 +370,7 @@ checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -257,12 +385,6 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -[[package]] -name = "bitmaps" -version = "3.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" - [[package]] name = "bitvec" version = "1.0.1" @@ -299,6 +421,15 @@ 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" @@ -320,20 +451,23 @@ dependencies = [ "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.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "byteorder" @@ -355,21 +489,23 @@ checksum = "6bd91ee7b2422bcb158d90ef4d14f75ef67f340943fc4149891dcce8f8b972a3" [[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]] @@ -386,16 +522,52 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[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,7 +576,7 @@ 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", ] @@ -437,7 +609,7 @@ dependencies = [ "anstyle", "clap_lex", "strsim", - "terminal_size", + "terminal_size 0.4.3", ] [[package]] @@ -446,10 +618,10 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -467,6 +639,15 @@ 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" @@ -482,6 +663,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[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 = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "console" version = "0.16.2" @@ -491,7 +689,7 @@ dependencies = [ "encode_unicode", "libc", "once_cell", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -501,6 +699,12 @@ 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" @@ -523,6 +727,12 @@ 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,12 +742,30 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a62ec9ff5f7965e4d7280bd5482acd20aadb50d632cf6c1d74493856b011fa73" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -578,6 +806,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.5.5" @@ -600,6 +834,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -607,9 +850,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,7 +867,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -648,7 +891,60 @@ checksum = "91850c0efee1e6dffcea4e5841ff3a71db80575a79f933d0c77e41c6fe0973d1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", +] + +[[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 = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "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]] @@ -669,7 +965,7 @@ 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 +980,61 @@ 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=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd#6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd" +dependencies = [ + "const-oid 0.9.6", + "corncobs", + "dice-util-barcode", + "hubpack", + "serde", + "serde-big-array", + "thiserror 2.0.18", + "x509-cert", + "zerocopy", +] + +[[package]] +name = "dice-util-barcode" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd#6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "dice-verifier" +version = "0.3.0-pre0" +source = "git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd#6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd" +dependencies = [ + "attest-data 0.5.0 (git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd)", + "const-oid 0.9.6", + "ed25519-dalek", + "env_logger", + "hex", + "hubpack", + "libipcc 0.1.0 (git+https://github.com/oxidecomputer/ipcc-rs?rev=dbaad520e1f5ae32c10db16ce176f9c24de95652)", + "log", + "p384", + "rats-corim", + "sha3", + "tempfile", + "thiserror 2.0.18", + "x509-cert", ] [[package]] @@ -702,12 +1043,23 @@ 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" @@ -716,15 +1068,16 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[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,26 +1088,26 @@ 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 1.11.0", "hyper-util", - "indexmap 2.13.0", + "indexmap 2.14.0", "multer", "openapiv3", "paste", "percent-encoding", - "rustls 0.22.4", + "rustls 0.23.43", "rustls-pemfile 2.2.0", - "schemars", + "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", @@ -762,8 +1115,9 @@ dependencies = [ "slog-term", "thiserror 2.0.18", "tokio", - "tokio-rustls 0.25.0", - "toml", + "tokio-rustls 0.26.4", + "tokio-util", + "toml 1.1.4+spec-1.1.0", "uuid", "version_check", "waitgroup", @@ -771,19 +1125,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 +1166,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,6 +1194,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2", + "signature", "subtle", "zeroize", ] @@ -843,10 +1213,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", @@ -975,9 +1346,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 +1356,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" @@ -1021,6 +1402,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 +1431,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 +1446,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 +1456,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 +1473,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 +1514,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1163,10 +1549,27 @@ 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", + "libc", + "r-efi 6.0.0", +] + +[[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" @@ -1190,7 +1593,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.13.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1199,23 +1602,34 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", - "indexmap 2.13.0", + "http 1.5.0", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hash32" version = "0.2.1" @@ -1231,6 +1645,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" @@ -1267,6 +1691,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 +1718,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 +1758,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 +1780,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", @@ -1364,7 +1806,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.5.0", ] [[package]] @@ -1375,7 +1817,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.5.0", "http-body 1.0.1", "pin-project-lite", ] @@ -1398,11 +1840,41 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] [[package]] name = "hyper" @@ -1430,22 +1902,21 @@ dependencies = [ [[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", + "h2 0.4.15", + "http 1.5.0", "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec 1.15.1", "tokio", "want", @@ -1471,10 +1942,10 @@ 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", + "http 1.5.0", + "hyper 1.11.0", "hyper-util", - "rustls 0.23.35", + "rustls 0.23.43", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -1489,7 +1960,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.8.1", + "hyper 1.11.0", "hyper-util", "native-tls", "tokio", @@ -1499,25 +1970,25 @@ dependencies = [ [[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 1.5.0", "http-body 1.0.1", - "hyper 1.8.1", + "hyper 1.11.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", - "system-configuration 0.6.1", + "socket2 0.5.10", + "system-configuration 0.7.0", "tokio", + "tower-layer", "tower-service", "tracing", "windows-registry", @@ -1628,6 +2099,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" @@ -1649,31 +2126,6 @@ dependencies = [ "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,12 +2139,12 @@ 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.0", "serde", "serde_core", ] @@ -1705,7 +2157,7 @@ checksum = "9375e112e4b463ec1b1c6c011953545c65a30164fbab5b581df32b3abf0dcb88" dependencies = [ "console", "portable-atomic", - "unicode-width", + "unicode-width 0.2.2", "unit-prefix", "web-time", ] @@ -1752,6 +2204,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" @@ -1773,6 +2231,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1783,6 +2251,42 @@ dependencies = [ "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", + "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 = "lazy_static" version = "1.5.0" @@ -1794,9 +2298,29 @@ 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 = "libipcc" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/ipcc-rs?rev=524eb8f125003dff50b9703900c6b323f00f9e1b#524eb8f125003dff50b9703900c6b323f00f9e1b" +dependencies = [ + "cfg-if", + "libc", + "thiserror 1.0.69", +] + +[[package]] +name = "libipcc" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/ipcc-rs?rev=dbaad520e1f5ae32c10db16ce176f9c24de95652#dbaad520e1f5ae32c10db16ce176f9c24de95652" +dependencies = [ + "cfg-if", + "libc", + "thiserror 1.0.69", +] [[package]] name = "libm" @@ -1848,7 +2372,7 @@ version = "0.3.5" source = "git+https://github.com/oxidecomputer/lpc55_support#205d47d497aa59985136b3c5eddaa0a39da203a2" dependencies = [ "byteorder", - "const-oid", + "const-oid 0.9.6", "crc-any", "der", "env_logger", @@ -1903,17 +2427,59 @@ 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", + "once_cell", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size 0.1.17", + "textwrap", + "thiserror 1.0.69", + "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 = "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 +2495,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http 1.5.0", "httparse", "memchr", "mime", @@ -1996,16 +2562,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" @@ -2025,9 +2581,9 @@ dependencies = [ [[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" @@ -2049,6 +2605,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" + +[[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" @@ -2121,7 +2689,7 @@ 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", ] @@ -2149,7 +2717,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -2170,6 +2738,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + [[package]] name = "oxnet" version = "0.1.6" @@ -2177,7 +2751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a1d55676fd6e2fe06971c05d176c7aca3d2b7d6ed1244ae8026beb1e7b89058" dependencies = [ "ipnetwork", - "schemars", + "schemars 0.8.22", "serde", "serde_json", ] @@ -2262,7 +2836,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec 1.15.1", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2325,7 +2899,7 @@ source = "git+https://github.com/oxidecomputer/permission-slip#b8dfbcba8cd5d159c dependencies = [ "blake3", "clap", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "thiserror 1.0.69", @@ -2338,12 +2912,6 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkcs1" version = "0.7.5" @@ -2372,10 +2940,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[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", + "p384", + "pem-rfc7468", + "pkcs8", + "rand 0.8.5", + "rsa", + "sha1 0.10.6", + "sha2", + "sha3", + "signature", + "spki", + "x509-cert", + "zeroize", +] [[package]] name = "portable-atomic" @@ -2435,7 +3027,31 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit", + "toml_edit 0.23.9", +] + +[[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]] @@ -2479,17 +3095,17 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f6d9109b04e005bbdec84cacec7e81cc15533f2b5dc505f0defc212d270c15" 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", + "syn 2.0.119", "thiserror 2.0.18", "typify", "unicode-ident", @@ -2505,22 +3121,12 @@ dependencies = [ "proc-macro2", "progenitor-impl", "quote", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "serde_tokenstream", "serde_yaml", - "syn 2.0.115", -] - -[[package]] -name = "psm" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" -dependencies = [ - "ar_archive_writer", - "cc", + "syn 2.0.119", ] [[package]] @@ -2548,6 +3154,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" @@ -2624,12 +3236,18 @@ dependencies = [ ] [[package]] -name = "rand_xoshiro" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +name = "rats-corim" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/rats-corim#f0d5d5168d3d31487a56df32c676b0c6240bcc6b" dependencies = [ - "rand_core 0.9.5", + "ciborium", + "ciborium-io", + "clap", + "hex", + "serde", + "serde_with", + "strum", + "thiserror 2.0.18", ] [[package]] @@ -2651,6 +3269,26 @@ dependencies = [ "bitflags 2.10.0", ] +[[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" @@ -2742,11 +3380,11 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.12", - "http 1.4.0", + "h2 0.4.15", + "http 1.5.0", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", + "hyper 1.11.0", "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", @@ -2813,8 +3451,8 @@ version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -2832,7 +3470,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,19 +3480,23 @@ dependencies = [ "futures", "futures-util", "hex", - "imbl", "itertools", - "pollster", "rand 0.8.5", "seq-macro", + "smallvec 1.15.1", "static_assertions", "thiserror 2.0.18", "tinyvec", "tokio", "tokio-stream", - "tokio-util", ] +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2891,24 +3533,12 @@ dependencies = [ [[package]] name = "rustls" -version = "0.22.4" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ + "aws-lc-rs", "log", - "ring", - "rustls-pki-types", - "rustls-webpki 0.102.8", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls" -version = "0.23.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" -dependencies = [ "once_cell", "rustls-pki-types", "rustls-webpki 0.103.8", @@ -2953,23 +3583,13 @@ dependencies = [ "untrusted", ] -[[package]] -name = "rustls-webpki" -version = "0.102.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2998,7 +3618,7 @@ dependencies = [ "nix", "radix_trie", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", "utf8parse", "windows-sys 0.60.2", ] @@ -3010,12 +3630,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] -name = "safe_arch" -version = "0.7.4" +name = "salty" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +checksum = "b947325a585e90733e0e9ec097228f40b637cc346f9bd68f84d5c6297d0fcfef" dependencies = [ - "bytemuck", + "subtle", + "zeroize", ] [[package]] @@ -3042,6 +3663,30 @@ dependencies = [ "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 +3696,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -3076,11 +3721,20 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ "zeroize", ] @@ -3109,9 +3763,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", @@ -3133,6 +3787,15 @@ dependencies = [ "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" @@ -3161,7 +3824,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -3172,14 +3835,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 +3864,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 +3904,45 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +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", @@ -3252,8 +3956,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "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 = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -3263,8 +3978,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,6 +3998,12 @@ 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" @@ -3288,10 +4019,16 @@ 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 = "slab" version = "0.4.11" @@ -3305,7 +4042,7 @@ 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", @@ -3347,6 +4084,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" @@ -3388,6 +4144,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + [[package]] name = "socket2" version = "0.5.10" @@ -3400,12 +4162,12 @@ dependencies = [ [[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]] @@ -3427,6 +4189,48 @@ dependencies = [ "der", ] +[[package]] +name = "sprockets-tls" +version = "0.1.0" +source = "git+https://github.com/oxidecomputer/sprockets.git?rev=68a4b3bf819722f9f57a3f0c99e1393ed01ba392#68a4b3bf819722f9f57a3f0c99e1393ed01ba392" +dependencies = [ + "anyhow", + "attest-data 0.5.0 (git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd)", + "camino", + "cfg-if", + "clap", + "dice-mfg-msgs", + "dice-verifier", + "ed25519-dalek", + "hubpack", + "libipcc 0.1.0 (git+https://github.com/oxidecomputer/ipcc-rs?rev=524eb8f125003dff50b9703900c6b323f00f9e1b)", + "pem-rfc7468", + "rustls 0.23.43", + "secrecy", + "serde", + "sha2", + "sha3", + "slog", + "slog-async", + "slog-error-chain", + "slog-term", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.26.4", + "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=68a4b3bf819722f9f57a3f0c99e1393ed01ba392#68a4b3bf819722f9f57a3f0c99e1393ed01ba392" +dependencies = [ + "camino", + "pki-playground", +] + [[package]] name = "ssh-cipher" version = "0.2.0" @@ -3495,19 +4299,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stacker" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.61.2", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -3520,12 +4311,70 @@ 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 = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +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-hyperlinks" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "supports-unicode" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f850c19edd184a205e883199a261ed44471c81e39bd95b1357f5febbef00e77a" +dependencies = [ + "is-terminal", +] + [[package]] name = "sush-api" version = "0.1.0" @@ -3534,8 +4383,8 @@ dependencies = [ "chrono", "dropshot", "http-range-header", - "hyper 1.8.1", - "schemars", + "hyper 1.11.0", + "schemars 0.8.22", "serde", "sled-hardware-types", "sush-common", @@ -3555,7 +4404,7 @@ dependencies = [ "clap", "ed25519-dalek", "futures", - "http 1.4.0", + "http 1.5.0", "http-range-header", "humantime", "indicatif", @@ -3572,7 +4421,7 @@ dependencies = [ "rustyline", "serde", "serde_json", - "shlex", + "shlex 1.3.0", "signature", "sled-hardware-types", "ssh-key", @@ -3598,7 +4447,7 @@ dependencies = [ "chrono", "crypto-bigint", "ed25519-dalek", - "http 1.4.0", + "http 1.5.0", "http-range-header", "p256", "pem-rfc7468", @@ -3606,7 +4455,7 @@ dependencies = [ "rand_core 0.6.4", "rlimit", "rustix", - "schemars", + "schemars 0.8.22", "serde", "serde_json", "sha2", @@ -3625,20 +4474,22 @@ 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 1.11.0", "libc", "lru", "memmap2", @@ -3647,13 +4498,15 @@ dependencies = [ "rand_core 0.6.4", "rumors", "rustix", - "schemars", + "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", @@ -3707,9 +4560,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.115" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -3739,7 +4603,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -3755,9 +4619,9 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", "core-foundation", @@ -3818,6 +4682,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +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.3" @@ -3828,6 +4702,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[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 = "thiserror" version = "1.0.69" @@ -3854,7 +4739,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -3865,7 +4750,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -3879,32 +4764,31 @@ dependencies = [ [[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", @@ -3953,14 +4837,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 +4852,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -3985,13 +4869,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", + "syn 3.0.3", ] [[package]] @@ -4014,24 +4898,13 @@ dependencies = [ "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", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.35", + "rustls 0.23.43", "tokio", ] @@ -4066,7 +4939,6 @@ checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", - "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -4074,17 +4946,38 @@ dependencies = [ [[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.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -4096,32 +4989,61 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +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.13", +] + [[package]] name = "toml_edit" version = "0.23.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" dependencies = [ - "indexmap 2.13.0", - "toml_datetime", + "indexmap 2.14.0", + "toml_datetime 0.7.3", "toml_parser", - "winnow", + "winnow 0.7.13", ] [[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" @@ -4147,7 +5069,7 @@ dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", - "http 1.4.0", + "http 1.5.0", "http-body 1.0.1", "iri-string", "pin-project-lite", @@ -4201,20 +5123,20 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http 1.4.0", + "http 1.5.0", "httparse", "log", "rand 0.9.2", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "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" @@ -4232,16 +5154,16 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" dependencies = [ - "heck", + "heck 0.5.0", "log", "proc-macro2", "quote", "regress", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", - "syn 2.0.115", + "syn 2.0.119", "thiserror 2.0.18", "unicode-ident", ] @@ -4254,12 +5176,12 @@ checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" 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", ] @@ -4269,12 +5191,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -4331,15 +5265,15 @@ 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", ] @@ -4434,7 +5368,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -4486,16 +5420,6 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - [[package]] name = "winapi" version = "0.3.9" @@ -4526,7 +5450,7 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", + "windows-link", "windows-result", "windows-strings", ] @@ -4539,7 +5463,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -4550,15 +5474,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 +5489,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 +5500,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,7 +5509,7 @@ 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]] @@ -4627,7 +5545,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4667,7 +5585,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", @@ -4825,6 +5743,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "winreg" version = "0.50.0" @@ -4862,7 +5786,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", @@ -4908,7 +5832,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", "synstructure", ] @@ -4929,7 +5853,7 @@ checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -4949,7 +5873,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", "synstructure", ] @@ -4970,7 +5894,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] @@ -5003,7 +5927,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.119", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0d34593..5db6e99 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" @@ -36,7 +38,7 @@ rand = "0.8" rand_core = "0.6" reqwest = "0.12" 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"] } @@ -48,6 +50,8 @@ 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 = "68a4b3bf819722f9f57a3f0c99e1393ed01ba392" } +sprockets-tls-test-utils = { git = "https://github.com/oxidecomputer/sprockets.git", rev = "68a4b3bf819722f9f57a3f0c99e1393ed01ba392" } ssh-key = { version = "0.6", features = ["ed25519", "p256", "serde"] } tempfile = "3" thiserror = "1" @@ -59,3 +63,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/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 fb45435..e593fc3 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -19,6 +19,7 @@ test-support = [] blake3.workspace = true borsh.workspace = true bytes.workspace = true +camino.workspace = true bytesize.workspace = true chrono.workspace = true clap.workspace = true @@ -44,6 +45,7 @@ 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" } @@ -57,4 +59,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/gossip.rs b/server/src/gossip.rs new file mode 100644 index 0000000..01d7380 --- /dev/null +++ b/server/src/gossip.rs @@ -0,0 +1,364 @@ +// 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 crate::link::{CorpusSource, Linker, 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, + linker: transport.linker(), + 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, + linker: Linker, + 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.linker.advertised(); + 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 linker = self.linker.clone(); + let handle = self.dials.spawn(async move { + let result = match timeout(deadline, linker.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/lib.rs b/server/src/lib.rs index 256c523..1df6919 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -12,9 +12,11 @@ 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; diff --git a/server/src/link.rs b/server/src/link.rs new file mode 100644 index 0000000..75c728e --- /dev/null +++ b/server/src/link.rs @@ -0,0 +1,338 @@ +// 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. + +use std::io; +use std::net::{SocketAddr, SocketAddrV6}; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; + +use camino::Utf8PathBuf; +use rumors::link::routed::{Config, Dial, Endpoint, Incoming, LinkError, Listen, RoutedLink}; +use slog::{Logger, 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; + }); + } + } +} + +/// Dials one attested sprockets connection per link stream. +#[derive(Clone)] +pub struct SprocketsDial { + log: Logger, + config: SprocketsConfig, + corpus: CorpusSource, + timeout: Duration, +} + +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, + } + } +} + +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 config = self.config.clone(); + let corpus = (self.corpus)(); + 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. + 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) => Ok(SprocketsConn::new(joined.map_err(io::Error::other)??)), + Err(_) => Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("dialing {addr} timed out"), + )), + } + } +} + +/// 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::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 linker(&self) -> Linker { + Linker { + endpoint: self.endpoint.clone(), + advertised: SocketAddr::V6(self.bound), + } + } + + /// 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)) + } +} + +/// Establishes outbound links; clones are handles onto one endpoint. +#[derive(Clone)] +pub struct Linker { + endpoint: Endpoint, + advertised: SocketAddr, +} + +impl Linker { + /// The name peers dial us at, and the one the dial tiebreak compares. + pub fn advertised(&self) -> SocketAddr { + self.advertised + } + + /// Establish a link to the peer listening at `peer`. + pub async fn link(&self, peer: SocketAddr) -> Result { + self.endpoint.link(peer).await + } +} + +/// 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; + } + }, + } + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 36937ef..2e7e6ab 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -19,10 +19,11 @@ use x509_cert::der::DecodePem as _; use sush_api::sush_api_mod::api_description; use sush_server::executor::PathIsolation; +use sush_server::gossip::isolated; use sush_server::manager::JobManager; use sush_server::output::JobOutputDir; -use sush_server::{read_root_certs, seed_gossip}; 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")]; @@ -91,7 +92,7 @@ async fn main() -> Result<(), String> { }; // TODO: get/seed Rumors network - let gossip = seed_gossip(); + let gossip = isolated(seed_gossip()); #[cfg(feature = "test-support")] let roots = overridable_root_certs(&override_root_certs).await?; diff --git a/server/src/manager.rs b/server/src/manager.rs index 403c3bc..5349829 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -103,7 +103,7 @@ impl JobManager { path_isolation: PathIsolation, output_dir: JobOutputDir, own_baseboard: BaseboardId, - rumors: GossipNetwork, + universe: watch::Receiver, roots: &[impl AsRef], shutdown: CancellationToken, ) -> Result { @@ -113,7 +113,7 @@ impl JobManager { path_isolation, output_dir, own_baseboard, - rumors, + universe, &roots, shutdown, ) @@ -125,7 +125,7 @@ impl JobManager { path_isolation: PathIsolation, output_dir: JobOutputDir, own_baseboard: BaseboardId, - rumors: GossipNetwork, + universe: watch::Receiver, roots: &[Certificate], shutdown: CancellationToken, ) -> Result { @@ -137,7 +137,7 @@ impl JobManager { output_dir.clone(), own_baseboard.clone(), requests, - rumors, + universe, roots, shutdown, )?; diff --git a/server/src/output.rs b/server/src/output.rs index b8cf96e..58283f2 100644 --- a/server/src/output.rs +++ b/server/src/output.rs @@ -56,7 +56,6 @@ impl JobOutputFileStream { pub fn length(&self) -> u64 { self.length } - } impl Stream for JobOutputFileStream { @@ -123,8 +122,7 @@ impl OutputDirs { /// 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)) + 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 { diff --git a/server/src/state.rs b/server/src/state.rs index 1a4d6e6..7bd4675 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -287,10 +287,7 @@ impl State { /// 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 { + pub fn new(own_baseboard: BaseboardId, root_certs: &[Certificate]) -> Result { let certs = root_certs .iter() .map(|cert| { @@ -753,6 +750,11 @@ 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. #[allow(clippy::too_many_arguments)] pub fn run( log: Logger, @@ -760,7 +762,7 @@ impl StateManager { output_dir: JobOutputDir, own_baseboard: BaseboardId, mut requests: R, - rumors: GossipNetwork, + universe: watch::Receiver, roots: &[Certificate], shutdown: CancellationToken, ) -> Result<(watch::Receiver, JoinHandle<()>), KeyError> @@ -769,6 +771,7 @@ impl StateManager { { // We report our current state through a watch channel. let (tx_state, rx_state) = watch::channel(State::new(own_baseboard.clone(), roots)?); + 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 @@ -776,7 +779,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( @@ -787,7 +791,10 @@ 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, @@ -796,7 +803,7 @@ impl StateManager { // 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. @@ -806,16 +813,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. @@ -825,7 +836,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()); }, @@ -834,7 +845,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()); }, @@ -858,7 +869,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()); } @@ -867,11 +878,34 @@ impl StateManager { }, }, + // 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") + }); + *rumors = fresh; + } } }), )) diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs new file mode 100644 index 0000000..10f935c --- /dev/null +++ b/server/tests/common/mod.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/. + +//! 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::generate_id; +use sush_common::jobs::{JobId, JobStartRequest, SignedJob}; +use sush_common::keys::{EphemeralKey, KeyType, Signer as _}; +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 = generate_id(); + 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::generate()); + 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)) + .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..d0878fc --- /dev/null +++ b/server/tests/distributed.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/. + +//! 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_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 mgr = JobManager::new( + log.clone(), + PathIsolation::InsecureDisable, + JobOutputDir::fixed(output.path()), + baseboard.clone(), + 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::new(); + let session = Session::new(session_id.clone()); + a.mgr + .session_start(&authn_a, session_id.clone(), 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::new(); + b.mgr + .session_start(&authn_b, successor.clone(), 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..906f104 --- /dev/null +++ b/server/tests/link.rs @@ -0,0 +1,139 @@ +// 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 linker = self.a.linker(); + let peer = self.b.linker().advertised(); + let (linked, accepted) = join!(linker.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, linker.advertised()); + (link_a, link_b) + } +} + +impl Drop for TestNet { + fn drop(&mut self) { + self.shutdown.cancel(); + } +} + +#[tokio::test] +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] +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/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index 9665340..e8393a3 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -13,7 +13,6 @@ 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; @@ -31,6 +30,7 @@ use sush_common::jobs::{ SessionId, }; use sush_common::keys::{EphemeralKey, KeyError, KeyId, KeyType, Signer as _, pem_cert_chain}; +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, OutputDirs}; @@ -446,7 +446,7 @@ async fn root_certs_from_files() { PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), test_baseboard_id(), - seed_gossip(), + isolated(seed_gossip()), &[path], CancellationToken::new(), ) @@ -491,7 +491,7 @@ async fn bad_root_cert_files() { PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), test_baseboard_id(), - seed_gossip(), + isolated(seed_gossip()), &[path], CancellationToken::new(), ) @@ -520,7 +520,7 @@ async fn job_output_dir_moves() { PathIsolation::InsecureDisable, JobOutputDir::new(rx_dirs), test_baseboard_id(), - seed_gossip(), + isolated(seed_gossip()), &[root.cert().to_owned()], CancellationToken::new(), ) @@ -598,6 +598,69 @@ async fn job_output_dir_moves() { ); } +#[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(), + 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::new(); + let session = Session::new(session_id.clone()); + 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() { @@ -675,7 +738,7 @@ 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::with_root_certs( log, @@ -1149,15 +1212,15 @@ 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, + isolated(seed), from_ref(&root_cert), shutdown, ) @@ -1293,15 +1356,15 @@ 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, + isolated(seed), from_ref(&root_cert), shutdown, ) diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index fab0b60..5e34764 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -11,7 +11,6 @@ use bytes::Bytes; 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}; @@ -27,9 +26,10 @@ use sush_common::codephrases::generate_id; use sush_common::jobs::{JobId, JobStartRequest, VerifiedJob}; use sush_common::keys::{EphemeralKey, KeyType, Signer}; 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(); @@ -167,8 +167,9 @@ 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::with_root_certs( From cf499ebab896b4d2bb6796378cd941ae517a8ab6 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Fri, 7 Aug 2026 23:21:50 +0000 Subject: [PATCH 09/43] Drop Linker for rumors' Endpoint::local_addr Co-Authored-By: Claude Fable 5 --- server/src/gossip.rs | 14 ++++++++------ server/src/link.rs | 28 +++------------------------- server/tests/link.rs | 8 ++++---- 3 files changed, 15 insertions(+), 35 deletions(-) diff --git a/server/src/gossip.rs b/server/src/gossip.rs index 01d7380..9a83518 100644 --- a/server/src/gossip.rs +++ b/server/src/gossip.rs @@ -38,7 +38,9 @@ use tokio::time::{MissedTickBehavior, interval, timeout}; use tokio::{select, spawn}; use tokio_util::sync::CancellationToken; -use crate::link::{CorpusSource, Linker, SprocketsLink, Transport}; +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)] @@ -136,7 +138,7 @@ where let manager = Manager { log: log.new(o!("component" => "gossip manager")), config, - linker: transport.linker(), + endpoint: transport.endpoint(), transport, peers, rumors: seed, @@ -166,7 +168,7 @@ enum Stopped { struct Manager { log: Logger, config: GossipConfig, - linker: Linker, + endpoint: Endpoint, transport: Transport, peers: watch::Receiver>, rumors: Rumors, @@ -224,7 +226,7 @@ where /// 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.linker.advertised(); + let ours = *self.endpoint.local_addr(); self.peers .borrow() .iter() @@ -240,9 +242,9 @@ where continue; } let deadline = self.config.connect_timeout; - let linker = self.linker.clone(); + let endpoint = self.endpoint.clone(); let handle = self.dials.spawn(async move { - let result = match timeout(deadline, linker.link(peer)).await { + 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()), diff --git a/server/src/link.rs b/server/src/link.rs index 75c728e..9fccfd3 100644 --- a/server/src/link.rs +++ b/server/src/link.rs @@ -22,7 +22,7 @@ use std::task::{Context, Poll}; use std::time::Duration; use camino::Utf8PathBuf; -use rumors::link::routed::{Config, Dial, Endpoint, Incoming, LinkError, Listen, RoutedLink}; +use rumors::link::routed::{Config, Dial, Endpoint, Incoming, Listen, RoutedLink}; use slog::{Logger, o, warn}; use sprockets_tls::keys::SprocketsConfig; use sprockets_tls::{Client, Server}; @@ -219,11 +219,8 @@ impl Transport { } /// A cheap handle for establishing links, free to move into tasks. - pub fn linker(&self) -> Linker { - Linker { - endpoint: self.endpoint.clone(), - advertised: SocketAddr::V6(self.bound), - } + pub fn endpoint(&self) -> Endpoint { + self.endpoint.clone() } /// Receive the next link a peer established toward us, with the name it @@ -234,25 +231,6 @@ impl Transport { } } -/// Establishes outbound links; clones are handles onto one endpoint. -#[derive(Clone)] -pub struct Linker { - endpoint: Endpoint, - advertised: SocketAddr, -} - -impl Linker { - /// The name peers dial us at, and the one the dial tiebreak compares. - pub fn advertised(&self) -> SocketAddr { - self.advertised - } - - /// Establish a link to the peer listening at `peer`. - pub async fn link(&self, peer: SocketAddr) -> Result { - self.endpoint.link(peer).await - } -} - /// Yields this endpoint's inbound sprockets connections. /// /// Accepting is two phases, and the second attests the peer, so this runs a diff --git a/server/tests/link.rs b/server/tests/link.rs index 906f104..62978cc 100644 --- a/server/tests/link.rs +++ b/server/tests/link.rs @@ -66,12 +66,12 @@ impl TestNet { /// One fresh connected link pair: node 1 establishes, node 2 accepts. async fn link_pair(&mut self) -> (SprocketsLink, SprocketsLink) { - let linker = self.a.linker(); - let peer = self.b.linker().advertised(); - let (linked, accepted) = join!(linker.link(peer), self.b.accept()); + 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, linker.advertised()); + assert_eq!(from, *endpoint.local_addr()); (link_a, link_b) } } From bc41d4737e0acf160e7ecc2704a92ff15edb8555 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sun, 9 Aug 2026 07:10:07 +0000 Subject: [PATCH 10/43] Add the target grammar --- common/src/lib.rs | 1 + common/src/targets.rs | 213 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 common/src/targets.rs diff --git a/common/src/lib.rs b/common/src/lib.rs index c65b991..a88bc26 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -10,6 +10,7 @@ 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())); + } +} From 10cc2a714fee59d0836bb9580821a4fb0d0e27b5 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sun, 9 Aug 2026 07:53:11 +0000 Subject: [PATCH 11/43] Bump progenitor and reqwest for dropshot 0.17 --- Cargo.lock | 532 ++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 6 +- client/src/permslip.rs | 9 +- sush.json | 15 +- 4 files changed, 518 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8426daa..1fd5dfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,6 +520,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -663,6 +674,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[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" @@ -721,6 +742,16 @@ 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" @@ -1560,8 +1591,11 @@ 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]] @@ -1985,7 +2019,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.5", "system-configuration 0.7.0", "tokio", "tower-layer", @@ -2231,6 +2265,55 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[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.18", + "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" @@ -2400,6 +2483,12 @@ dependencies = [ "hashbrown 0.17.0", ] +[[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" @@ -2512,10 +2601,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -2726,6 +2815,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -2879,8 +2974,8 @@ dependencies = [ "oauth2", "once_cell", "permission-slip-common", - "progenitor", - "progenitor-client", + "progenitor 0.11.2", + "progenitor-client 0.11.2", "rand_core 0.6.4", "reqwest 0.12.24", "serde", @@ -3069,9 +3164,20 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2326f73d5326257514712436680ef8da4543ee47c0e9e0d501545c8909ee12e4" dependencies = [ - "progenitor-client", - "progenitor-impl", - "progenitor-macro", + "progenitor-client 0.11.2", + "progenitor-impl 0.11.2", + "progenitor-macro 0.11.2", +] + +[[package]] +name = "progenitor" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8ba1d77160e6d5c95bdf0792527f76bf528791093fa83015bc2908a0ba9d076" +dependencies = [ + "progenitor-client 0.14.0", + "progenitor-impl 0.14.0", + "progenitor-macro 0.14.0", ] [[package]] @@ -3089,6 +3195,21 @@ dependencies = [ "serde_urlencoded", ] +[[package]] +name = "progenitor-client" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8a874cf25a33cac7a01b9c1de87bcfbc8aea93f3156d09dcc3bee516a78926" +dependencies = [ + "bytes", + "futures-core", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_urlencoded", +] + [[package]] name = "progenitor-impl" version = "0.11.2" @@ -3107,7 +3228,29 @@ dependencies = [ "serde_json", "syn 2.0.119", "thiserror 2.0.18", - "typify", + "typify 0.4.3", + "unicode-ident", +] + +[[package]] +name = "progenitor-impl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e349eed84b9a1a6a5dbe478d335e3df73d32a93c5eefe571c9b8cb298aab5d" +dependencies = [ + "heck 0.5.0", + "http 1.5.0", + "indexmap 2.14.0", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "syn 2.0.119", + "thiserror 2.0.18", + "typify 0.6.2", "unicode-ident", ] @@ -3119,7 +3262,25 @@ checksum = "46596c574831739c661f22923fe587399c61f5e3e79b73cc9a93644c72248d84" dependencies = [ "openapiv3", "proc-macro2", - "progenitor-impl", + "progenitor-impl 0.11.2", + "quote", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_tokenstream", + "serde_yaml", + "syn 2.0.119", +] + +[[package]] +name = "progenitor-macro" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efa969a1349979c5f64347f204e794781a86d738206d75672e6c9493f5910002" +dependencies = [ + "openapiv3", + "proc-macro2", + "progenitor-impl 0.14.0", "quote", "schemars 0.8.22", "serde", @@ -3139,11 +3300,68 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.43", + "socket2 0.5.10", + "thiserror 2.0.18", + "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 0.23.43", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "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", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[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", ] @@ -3197,6 +3415,17 @@ dependencies = [ "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" @@ -3235,6 +3464,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.10.1", +] + [[package]] name = "rats-corim" version = "0.1.0" @@ -3291,9 +3535,9 @@ dependencies = [ [[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", @@ -3303,9 +3547,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", @@ -3314,9 +3558,9 @@ 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" @@ -3328,6 +3572,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "regress" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158a764437582235e3501f683b93a0a6f8d825d04a789dbe5ed30b8799b8908a" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + [[package]] name = "reqwest" version = "0.11.27" @@ -3408,7 +3662,51 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.15", + "http 1.5.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.11.0", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.43", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", ] @@ -3497,6 +3795,12 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3546,6 +3850,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -3570,9 +3886,37 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.43", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.8", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -3639,6 +3983,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.28" @@ -3745,7 +4098,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4029,6 +4395,22 @@ 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" @@ -4413,10 +4795,10 @@ dependencies = [ "pem-rfc7468", "permission-slip-client", "permission-slip-common", - "progenitor", - "progenitor-client", + "progenitor 0.14.0", + "progenitor-client 0.14.0", "rand 0.8.5", - "reqwest 0.12.24", + "reqwest 0.13.4", "rustix", "rustyline", "serde", @@ -4613,7 +4995,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys 0.5.0", ] @@ -4624,7 +5006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -5144,8 +5526,18 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" dependencies = [ - "typify-impl", - "typify-macro", + "typify-impl 0.4.3", + "typify-macro 0.4.3", +] + +[[package]] +name = "typify" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0b89f47309feaeb23c4509c15c9a04234f7deccef6f96c3bfe95319819a304" +dependencies = [ + "typify-impl 0.6.2", + "typify-macro 0.6.2", ] [[package]] @@ -5158,7 +5550,27 @@ dependencies = [ "log", "proc-macro2", "quote", - "regress", + "regress 0.10.5", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 2.0.119", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "typify-impl" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7b026f540b148b81043c720889dbb942b08659aa8a43f624ac4f04dbfc1861" +dependencies = [ + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "regress 0.11.1", "schemars 0.8.22", "semver", "serde", @@ -5182,14 +5594,31 @@ dependencies = [ "serde_json", "serde_tokenstream", "syn 2.0.119", - "typify-impl", + "typify-impl 0.4.3", +] + +[[package]] +name = "typify-macro" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ed96c57f06ae0839416b986921a98f18b220da63bbb243a8570a00c8492183" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.119", + "typify-impl 0.6.2", ] [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" @@ -5298,6 +5727,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" @@ -5394,6 +5833,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.85" @@ -5414,6 +5866,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.25.4" @@ -5436,6 +5897,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.48.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 5db6e99..90fc495 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,12 +31,12 @@ p256 = { version = "0.13", features = ["ecdsa"] } pem-rfc7468 = { version = "0.7", features = ["std"] } 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"] } rlimit = "0.10" rumors = { git = "https://github.com/oxidecomputer/rumors", rev = "66aea68b6578345275de3cf14151c4707345773f" } rustix = { version = "1", features = ["fs", "process", "pty", "termios"] } 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/sush.json b/sush.json index 7495feb..85422f9 100644 --- a/sush.json +++ b/sush.json @@ -360,13 +360,14 @@ } ], "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": {} From 5c9535981d5723457a0683ef9c43babe58bfe0d4 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sun, 9 Aug 2026 07:54:50 +0000 Subject: [PATCH 12/43] Target jobs at sleds --- client/src/commands.rs | 8 ++++++ common/src/borsh.rs | 11 ++++++++ common/src/jobs.rs | 25 +++++++++++++++++-- server/src/executor.rs | 1 + server/src/messages.rs | 40 ++++++++++++++++++++++++++++++ server/src/state.rs | 51 +++++++++++++++++++++++++------------- server/tests/common/mod.rs | 12 ++++++--- sush.json | 4 +++ tests/src/manager_tests.rs | 51 ++++++++++++++++++++++++++++++++++++-- tests/src/test_utils.rs | 2 ++ 10 files changed, 181 insertions(+), 24 deletions(-) diff --git a/client/src/commands.rs b/client/src/commands.rs index 9e66819..7e97e09 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -49,6 +49,7 @@ use sush_common::jobs::{ SignedJob, job_status_try_from_json_map, }; use sush_common::keys::{KeyError, KeyId, Signer as _}; +use sush_common::targets::Target; use crate::ByteStream; use crate::context::{Authz, CommandContext, OutputFormat}; @@ -457,6 +458,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")] @@ -855,6 +861,7 @@ async fn job( ref job_id, ref permslip_url, ref interactive, + ref target, .. }, }, @@ -900,6 +907,7 @@ async fn job( job_id.to_owned(), command, *interactive, + target.clone(), )); pin!(sign); ctx.job_signing_started(&job_id); diff --git a/common/src/borsh.rs b/common/src/borsh.rs index 1e2d4ab..e1966a9 100644 --- a/common/src/borsh.rs +++ b/common/src/borsh.rs @@ -13,6 +13,7 @@ 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 +62,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() diff --git a/common/src/jobs.rs b/common/src/jobs.rs index c54ead0..78fbf42 100644 --- a/common/src/jobs.rs +++ b/common/src/jobs.rs @@ -24,13 +24,15 @@ 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, + borsh_de_datetime, borsh_de_hash, borsh_de_job_id, borsh_de_target, borsh_ser_datetime, + borsh_ser_hash, borsh_ser_target, }; use crate::codephrases::{ InvalidCodephrase, WORD_SEPARATOR, decode_phrase, generate_id, id_phrase, }; use crate::interactive::InteractiveJobError; use crate::keys::{KeyId, Signed, ToBeSigned, Verified}; +use crate::targets::Target; /// A globally unique identifier for a job within a session. #[derive( @@ -256,6 +258,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 +274,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 +299,10 @@ impl JobStartRequest { pub fn interactive(&self) -> bool { self.interactive } + + pub fn target(&self) -> &Target { + &self.target + } } impl ToBeSigned for JobStartRequest { @@ -299,11 +318,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(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() } } diff --git a/server/src/executor.rs b/server/src/executor.rs index 1e96289..603d119 100644 --- a/server/src/executor.rs +++ b/server/src/executor.rs @@ -177,6 +177,7 @@ async fn job_spawn( job_id, command, interactive, + target: _, } = request.payload().clone(); let JobStartParams { limits: requested, diff --git a/server/src/messages.rs b/server/src/messages.rs index 7563bf3..483bc74 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -288,5 +288,45 @@ mod wire_format { ); } + #[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("zoo-zero".to_string()), + EncodedSignature { + r: "abandon".to_string(), + s: "zoo".to_string(), + flags: 0, + counter: 0, + }, + ); + let msg: VersionedMessage = Message::Request(Request::job( + KeyId::from("zoo-zero".to_string()), + JobRequest::Start(signed, JobStartParams::default()), + )) + .into(); + assert_wire_format( + msg, + b"\x00\x00\x02\x08\x00\x00\x00zoo-zero\x00\x3f\x00\x00\x00ab\ + andon-abandon-abandon-abandon-abandon-abandon-abandon-abil\ + ity\x0a\x00\x00\x00echo hello\x00\x05\x00\x00\x0014,16\x08\ + \x00\x00\x00zoo-zero\x07\x00\x00\x00abandon\x03\x00\x00\ + \x00zoo\x00\x00\x00\x00\x00\x10\x0e\x00\x00\x00\x00\x00\ + \x00\x00P\xd6\xdc\x01\x00\x00\x00\x00\xe4\x0bT\x02\x00\x00\ + \x00\x00\x00\x00\x00", + ); + } + // TODO: snapshot more messages } diff --git a/server/src/state.rs b/server/src/state.rs index 7bd4675..6e5045f 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -27,6 +27,7 @@ use x509_cert::der::Encode as _; use sush_api::JobStartParams; use sush_common::jobs::{Access, JobId, JobStatus, JobStatusMap, Session, SessionId, SignedJob}; use sush_common::keys::{KeyError, KeyId, Signature}; +use sush_common::targets::Cubbies; use crate::executor::{Executor, PathIsolation}; use crate::history::JobHistory; @@ -175,11 +176,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 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 +192,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. + // 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.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, - ); + if targeted { + 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, + ); + } } } @@ -240,6 +247,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,10 +256,13 @@ 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); @@ -281,6 +292,8 @@ 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, } impl State { @@ -307,6 +320,7 @@ impl State { tombstones: LruCache::new(MAX_TOMBSTONES), certs, roots: roots.clone(), + cubbies: Default::default(), }; new.validate_certs(&roots); for root in &roots { @@ -578,6 +592,7 @@ impl State { ); session.execute_ready_jobs( &self.own_baseboard, + &self.cubbies, &mut self.certs, &mut self.history, executor, @@ -623,12 +638,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, diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs index 10f935c..44dabd0 100644 --- a/server/tests/common/mod.rs +++ b/server/tests/common/mod.rs @@ -29,6 +29,7 @@ use sush_common::authn::{Challenge, ChallengeResponse, Identity, Nonce, RequestK use sush_common::codephrases::generate_id; 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; @@ -149,9 +150,14 @@ pub async fn fake_identity(key: &mut EphemeralKey) -> Identity { /// 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)) - .await - .unwrap() + root.sign(JobStartRequest::new( + job_id.to_owned(), + command, + false, + Target::All, + )) + .await + .unwrap() } pub fn sprockets_config(dir: &Utf8PathBuf, node: usize) -> SprocketsConfig { diff --git a/sush.json b/sush.json index 85422f9..29c6868 100644 --- a/sush.json +++ b/sush.json @@ -1361,6 +1361,10 @@ }, "interactive": { "type": "boolean" + }, + "target": { + "description": "The sleds this job runs on.", + "type": "string" } }, "required": [ diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index e8393a3..787443c 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -26,8 +26,8 @@ use sush_api::{JobStartParams, JobStopParams, JobWait}; use sush_client::context::Authz; use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, 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 _, pem_cert_chain}; use sush_server::gossip::isolated; @@ -1020,6 +1020,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::new(); + let mut session = Session::new(session_id.clone()); + 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.clone(), 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] diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index 5e34764..f2fc3c9 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -25,6 +25,7 @@ use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, No use sush_common::codephrases::generate_id; use sush_common::jobs::{JobId, JobStartRequest, VerifiedJob}; use sush_common::keys::{EphemeralKey, KeyType, Signer}; +use sush_common::targets::Target; use sush_server::executor::PathIsolation; use sush_server::gossip::isolated; use sush_server::output::{JobOutputDir, JobOutputFileStream}; @@ -81,6 +82,7 @@ impl SignJobRequest for EphemeralKey { job_id.to_owned(), command, interactive, + Target::All, )) .await .expect("failed to sign job") From 525349221f549f0983245b815130f1f59b132adf Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sun, 9 Aug 2026 17:51:05 +0000 Subject: [PATCH 13/43] Route the proxy by target --- Cargo.lock | 2 + Cargo.toml | 4 +- server/Cargo.toml | 2 + server/src/proxy.rs | 320 ++++++++++++++++++++++++++++----- tests/src/integration_tests.rs | 95 +++++++++- 5 files changed, 371 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1fd5dfb..79e6c30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4872,10 +4872,12 @@ dependencies = [ "http-body-util", "http-range-header", "hyper 1.11.0", + "hyper-util", "libc", "lru", "memmap2", "p256", + "percent-encoding", "pwd", "rand_core 0.6.4", "rumors", diff --git a/Cargo.toml b/Cargo.toml index 90fc495..7a077d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,13 +22,15 @@ 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"] } 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.14" diff --git a/server/Cargo.toml b/server/Cargo.toml index e593fc3..d27bbb3 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -30,10 +30,12 @@ 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 diff --git a/server/src/proxy.rs b/server/src/proxy.rs index 1c47043..814dd51 100644 --- a/server/src/proxy.rs +++ b/server/src/proxy.rs @@ -4,40 +4,95 @@ //! 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 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 sled_hardware_types::BaseboardId; +use slog::{Logger, debug, error, info, o, warn}; use tokio::io::copy_bidirectional; use tokio::net::{TcpListener, TcpStream}; -use tokio::{select, spawn}; +use tokio::sync::watch; +use tokio::{select, spawn, try_join}; 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>; + 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, + targets: watch::Receiver, shutdown: CancellationToken, ) -> io::Result { let listener = TcpListener::bind(local_addr).await?; let local_addr = listener.local_addr()?; + let router = Arc::new(Router { + targets, + default: Mutex::new(None), + }); spawn(listen( log.new(o!("component" => "proxy")), listener, - remote_addr, + router, shutdown.clone(), )); - info!(log, "started proxy server"); + info!(log, "started proxy server"; "local_addr" => local_addr); Ok(Self { local_addr, shutdown, @@ -53,10 +108,79 @@ 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 query 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 == "target") + .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, + router: Arc, shutdown: CancellationToken, ) { loop { @@ -64,7 +188,8 @@ 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)); + spawn(serve(log, client, router.clone())); } Err(err) => { error!(log, "accept failed"; "error" => %err); @@ -80,41 +205,152 @@ 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: TcpStream, router: Arc) { + 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?target=test%20part:0000")), + Some(Ok(target("test part:0000"))), + ); + assert_eq!( + named_target(&request("/jobs/some-job/attach/*?target=14,16")), + Some(Ok(target("14,16"))), + ); + assert_eq!(named_target(&request("/jobs/some-job/attach/*")), None); + assert_eq!(named_target(&request("/iam?target=*")), 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/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index 0e16e3d..a0f9342 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -4,6 +4,7 @@ //! Oxide Support Shell integration tests. +use std::collections::BTreeMap; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -13,6 +14,7 @@ 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; @@ -24,6 +26,8 @@ use sush_api::sush_api_mod::api_description; 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::targets::Cubbies; +use sush_server::proxy::Targets; use sush_server::{ApiServer, ProxyServer}; use crate::test_utils::{ @@ -137,16 +141,15 @@ 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(), - server.local_addr(), - shutdown_proxy.clone(), - ) - .await - .expect("can't start proxy server"); + let proxy = ProxyServer::start(&log, local_addr(), rx_targets, shutdown_proxy.clone()) + .await + .expect("can't start proxy server"); let proxy_addr = proxy.local_addr(); assert_ne!(server_addr, proxy_addr); @@ -169,6 +172,80 @@ async fn client_proxy_server() { .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::new()); + 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); + } + + // 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"); } From cde0a9f9ea7bcfd9e5e82a9456d1258b47217067 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sun, 9 Aug 2026 18:24:19 +0000 Subject: [PATCH 14/43] Resolve targets through the proxy --- api/src/lib.rs | 16 +++++- client/src/commands.rs | 95 ++++++++++++++++++++-------------- server/src/proxy.rs | 10 ++-- server/src/server.rs | 16 +++--- sush.json | 35 +++++++++++-- tests/src/integration_tests.rs | 12 +++++ 6 files changed, 127 insertions(+), 57 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 8acb507..e08e7a5 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>; @@ -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/src/commands.rs b/client/src/commands.rs index 7e97e09..ed7b2c0 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -49,7 +49,7 @@ use sush_common::jobs::{ SignedJob, job_status_try_from_json_map, }; use sush_common::keys::{KeyError, KeyId, Signer as _}; -use sush_common::targets::Target; +use sush_common::targets::{SledId, Target}; use crate::ByteStream; use crate::context::{Authz, CommandContext, OutputFormat}; @@ -395,9 +395,9 @@ pub enum JobCommand { /// 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: Target, #[clap(flatten)] output: JobOutput, @@ -406,9 +406,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: Target, #[clap(flatten)] output: JobOutput, @@ -420,9 +420,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: Target, }, /// Show status of previously started jobs. @@ -550,6 +550,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 { @@ -576,13 +577,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))) } @@ -591,6 +593,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 @@ -599,7 +617,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?) } @@ -943,17 +961,17 @@ async fn job( (JobCommand::Status { job_id }, Some(client)) => job_status(ctx, client, &job_id).await, (JobCommand::Stdout { target, output }, Some(client)) => { - let target = resolve_target(ctx, client, &target).await?; + let target = resolve_target(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?; + let target = resolve_target(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 = resolve_target(client, &target).await?; job_attach(ctx, client, &job_id, &target).await?; Ok(()) } @@ -989,9 +1007,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(); @@ -1092,7 +1110,7 @@ async fn job_start( // Show the job status and output. job_status(ctx, client, &job_id).await?; 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) @@ -1200,7 +1218,7 @@ 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 || { + with_login_via(ctx, client, Some(target), async || { client.job_status().job_id(&job_id).send().await }) .await? @@ -1303,7 +1321,7 @@ 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() @@ -1393,7 +1411,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) @@ -1417,22 +1435,21 @@ async fn job_attach( } } -async fn resolve_target( - ctx: &mut impl CommandContext, - client: &Client, - target: &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)? - }) +/// 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()) } /// What went wrong parsing, preparing, or executing a client command. diff --git a/server/src/proxy.rs b/server/src/proxy.rs index 814dd51..8e0d712 100644 --- a/server/src/proxy.rs +++ b/server/src/proxy.rs @@ -151,7 +151,7 @@ impl Router { } /// The target a request names, if any. A `*` path segment names no -/// particular sled, so the query may still route. +/// 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(); @@ -161,7 +161,7 @@ fn named_target(request: &Request) -> Option> { query.split('&').find_map(|param| { param .split_once('=') - .filter(|(key, _)| *key == "target") + .filter(|(key, _)| *key == "via") .map(|(_, value)| value) }) }), @@ -313,15 +313,15 @@ mod test { Some(Ok(target("test part:0000"))), ); assert_eq!( - named_target(&request("/iam?target=test%20part:0000")), + named_target(&request("/iam?via=test%20part:0000")), Some(Ok(target("test part:0000"))), ); assert_eq!( - named_target(&request("/jobs/some-job/attach/*?target=14,16")), + 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?target=*")), 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"))), diff --git a/server/src/server.rs b/server/src/server.rs index 4e718ff..304385e 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -25,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}; @@ -111,6 +111,7 @@ impl SushApi for ApiServer { async fn iam( ctx: RequestContext, headers: Header, + _query: QueryParams, body: TypedBody>, ) -> Result, HttpError> { let mgr = ctx.context(); @@ -310,6 +311,7 @@ impl SushApi for ApiServer { ctx: RequestContext, headers: Header, params: PathParams, + _query: QueryParams, ) -> Result, HttpError> { let mgr = ctx.context(); let headers = headers.into_inner(); @@ -350,6 +352,7 @@ impl SushApi for ApiServer { ctx: RequestContext, headers: Header, params: PathParams, + _query: QueryParams, upgrade: WebsocketUpgrade, ) -> WebsocketEndpointResult { let mgr = ctx.context(); @@ -405,13 +408,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/sush.json b/sush.json index 29c6868..c56a376 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": { @@ -357,6 +366,15 @@ "schema": { "type": "string" } + }, + { + "in": "query", + "name": "via", + "description": "Where a proxy should route this request.", + "schema": { + "nullable": true, + "type": "string" + } } ], "responses": { @@ -417,6 +435,15 @@ "schema": { "$ref": "#/components/schemas/JobOutputStream" } + }, + { + "in": "query", + "name": "via", + "description": "Where a proxy should route this request.", + "schema": { + "nullable": true, + "type": "string" + } } ], "responses": { @@ -892,13 +919,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" } } diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index a0f9342..d471b29 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -239,6 +239,18 @@ async fn client_proxy_server() { 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(); From da05ac3a789b5b0c4ddfe86447f7836cdf129bb4 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sun, 9 Aug 2026 19:31:15 +0000 Subject: [PATCH 15/43] Gossip identities across the rack --- api/src/lib.rs | 6 +-- client/src/commands.rs | 3 +- common/src/authn.rs | 36 ++++++++++++++- common/src/keys.rs | 15 +++++++ server/src/manager.rs | 82 ++++++++++++++++++++++++--------- server/src/messages.rs | 77 ++++++++++++++++++++++++++++++- server/src/server.rs | 4 +- server/src/state.rs | 92 +++++++++++++++++++++++++++++++++++++- sush.json | 10 ++++- tests/src/manager_tests.rs | 76 +++++++++++++++++++++++++++++-- 10 files changed, 366 insertions(+), 35 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index e08e7a5..384ba6a 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -89,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. diff --git a/client/src/commands.rs b/client/src/commands.rs index ed7b2c0..3999bce 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -321,7 +321,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 }, } @@ -672,7 +671,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) diff --git a/common/src/authn.rs b/common/src/authn.rs index 54ca38f..4805669 100644 --- a/common/src/authn.rs +++ b/common/src/authn.rs @@ -30,6 +30,8 @@ use std::str::FromStr; use std::time::Duration; use blake3::Hasher; +use borsh::io::{Error, ErrorKind, Read, Write}; +use borsh::{BorshDeserialize, BorshSerialize}; use chrono::{DateTime, Utc}; use crypto_bigint::{ArrayEncoding as _, U256}; use ed25519_dalek::{Signature as Ed25519Signature, Signer as _, SigningKey, VerifyingKey}; @@ -64,7 +66,18 @@ 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)] +#[derive( + BorshDeserialize, + BorshSerialize, + Clone, + Debug, + Deserialize, + Eq, + Hash, + JsonSchema, + PartialEq, + Serialize, +)] pub struct Nonce(String); impl Nonce { @@ -195,6 +208,22 @@ impl RequestVerifier { } } +impl BorshSerialize for RequestVerifier { + fn serialize(&self, writer: &mut W) -> borsh::io::Result<()> { + writer.write_all(self.as_bytes()) + } +} + +impl BorshDeserialize for RequestVerifier { + fn deserialize_reader(reader: &mut R) -> borsh::io::Result { + let mut bytes = [0; 32]; + reader.read_exact(&mut bytes)?; + VerifyingKey::from_bytes(&bytes) + .map(Self) + .map_err(|err| Error::new(ErrorKind::InvalidData, err)) + } +} + impl fmt::Display for RequestVerifier { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( @@ -381,13 +410,16 @@ 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"; diff --git a/common/src/keys.rs b/common/src/keys.rs index 59ba037..46b09b8 100644 --- a/common/src/keys.rs +++ b/common/src/keys.rs @@ -14,6 +14,7 @@ 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}; @@ -157,6 +158,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() diff --git a/server/src/manager.rs b/server/src/manager.rs index 5349829..8065277 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -10,7 +10,7 @@ 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; @@ -36,7 +36,7 @@ use sush_common::keys::{KeyError, KeyId, SshPublicKey}; 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}; @@ -49,13 +49,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); @@ -87,7 +80,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 @@ -145,7 +137,6 @@ impl JobManager { 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, @@ -183,6 +174,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( @@ -277,6 +279,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"); }; @@ -305,7 +327,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"); } @@ -325,13 +347,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, @@ -340,7 +363,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> { @@ -354,15 +380,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(()) } @@ -380,6 +418,10 @@ 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 diff --git a/server/src/messages.rs b/server/src/messages.rs index 483bc74..8309cfa 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); @@ -84,6 +85,7 @@ pub mod v0 { Cert(Box>), Session(Box>), Job(Box>), + Identity(Box>), } impl Request { @@ -99,6 +101,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 +122,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", + }, } } } @@ -153,6 +163,23 @@ pub mod v0 { 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), @@ -328,5 +355,53 @@ mod wire_format { ); } + #[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 ["abandon", "ability"] { + evidence.extend((nonce.len() as u32).to_le_bytes()); + evidence.extend(nonce.as_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("zoo-zero".to_string()), + EncodedSignature { + r: "abandon".to_string(), + s: "zoo".to_string(), + flags: 0, + counter: 0, + }, + ); + let msg: VersionedMessage = Message::Request(Request::identity( + KeyId::from("zoo-zero".to_string()), + IdentityRequest::Login(public_key, signed), + )) + .into(); + assert_wire_format( + msg, + b"\x00\x00\x03\x08\x00\x00\x00zoo-zero\x00Z\x00\x00\x00ssh-e\ + d25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG\ + 1KaT0PtFDJ13gEiGB test@sush\x07\x00\x00\x00abandon\x07\x00\ + \x00\x00abilityXfffffffffffffffffffffffffffffff\x08\x00\ + \x00\x00zoo-zero\x07\x00\x00\x00abandon\x03\x00\x00\x00zoo\ + \x00\x00\x00\x00\x00", + ); + } + // TODO: snapshot more messages } diff --git a/server/src/server.rs b/server/src/server.rs index 304385e..621464a 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -139,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(); @@ -146,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()) } diff --git a/server/src/state.rs b/server/src/state.rs index 6e5045f..5892316 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -25,15 +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; @@ -59,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 { @@ -294,6 +314,10 @@ pub struct State { 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 { @@ -321,6 +345,8 @@ impl State { 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 { @@ -400,6 +426,14 @@ 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() + } + fn update( &mut self, log: &Logger, @@ -666,6 +700,47 @@ 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. @@ -1061,6 +1136,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/sush.json b/sush.json index c56a376..c6b095e 100644 --- a/sush.json +++ b/sush.json @@ -242,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": [ { @@ -260,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": { diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index 787443c..484dfba 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -24,7 +24,7 @@ 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::*, JobStartRequest, JobStatus, ProcessError, Session, SessionId, @@ -32,7 +32,7 @@ use sush_common::jobs::{ use sush_common::keys::{EphemeralKey, KeyError, KeyId, KeyType, Signer as _, pem_cert_chain}; 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::messages::v0::{CertRequest, IdentityRequest, Message, Request, SessionRequest}; use sush_server::output::{JobOutputDir, OutputDirs}; use sush_server::{JobError, JobManager, seed_gossip}; @@ -1082,7 +1082,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")) @@ -1092,6 +1092,76 @@ 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::generate()), 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::generate()), 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. From 24923235f0dcf15cf39951d88be72c40158d57e0 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sun, 9 Aug 2026 19:37:09 +0000 Subject: [PATCH 16/43] Snapshot wire formats as files --- server/src/messages.rs | 61 ++++++------------ .../tests/output/identity-login-request.bin | Bin 0 -> 199 bytes server/tests/output/job-start-request.bin | Bin 0 -> 170 bytes .../output/session-allow-attach-request.bin | Bin 0 -> 50 bytes .../output/session-deny-attach-request.bin | Bin 0 -> 49 bytes server/tests/output/session-start-request.bin | Bin 0 -> 35 bytes server/tests/output/session-stop-request.bin | Bin 0 -> 35 bytes 7 files changed, 20 insertions(+), 41 deletions(-) create mode 100644 server/tests/output/identity-login-request.bin create mode 100644 server/tests/output/job-start-request.bin create mode 100644 server/tests/output/session-allow-attach-request.bin create mode 100644 server/tests/output/session-deny-attach-request.bin create mode 100644 server/tests/output/session-start-request.bin create mode 100644 server/tests/output/session-stop-request.bin diff --git a/server/src/messages.rs b/server/src/messages.rs index 8309cfa..37a3283 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -235,21 +235,31 @@ pub mod v0 { #[cfg(test)] mod wire_format { + use std::env; + use std::fs::{read, write}; + use super::v0::*; use super::*; - /// 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"); } @@ -261,10 +271,7 @@ mod wire_format { SessionRequest::Start(SessionId::from("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] @@ -274,10 +281,7 @@ mod wire_format { SessionRequest::Stop(SessionId::from("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] @@ -291,11 +295,7 @@ mod wire_format { ), )) .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] @@ -308,11 +308,7 @@ mod wire_format { ), )) .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] @@ -343,16 +339,7 @@ mod wire_format { JobRequest::Start(signed, JobStartParams::default()), )) .into(); - assert_wire_format( - msg, - b"\x00\x00\x02\x08\x00\x00\x00zoo-zero\x00\x3f\x00\x00\x00ab\ - andon-abandon-abandon-abandon-abandon-abandon-abandon-abil\ - ity\x0a\x00\x00\x00echo hello\x00\x05\x00\x00\x0014,16\x08\ - \x00\x00\x00zoo-zero\x07\x00\x00\x00abandon\x03\x00\x00\ - \x00zoo\x00\x00\x00\x00\x00\x10\x0e\x00\x00\x00\x00\x00\ - \x00\x00P\xd6\xdc\x01\x00\x00\x00\x00\xe4\x0bT\x02\x00\x00\ - \x00\x00\x00\x00\x00", - ); + assert_wire_format("job-start-request", msg); } #[test] @@ -392,15 +379,7 @@ mod wire_format { IdentityRequest::Login(public_key, signed), )) .into(); - assert_wire_format( - msg, - b"\x00\x00\x03\x08\x00\x00\x00zoo-zero\x00Z\x00\x00\x00ssh-e\ - d25519 AAAAC3NzaC1lZDI1NTE5AAAAILM+rvN+ot98qgEN796jTiQfZfG\ - 1KaT0PtFDJ13gEiGB test@sush\x07\x00\x00\x00abandon\x07\x00\ - \x00\x00abilityXfffffffffffffffffffffffffffffff\x08\x00\ - \x00\x00zoo-zero\x07\x00\x00\x00abandon\x03\x00\x00\x00zoo\ - \x00\x00\x00\x00\x00", - ); + assert_wire_format("identity-login-request", msg); } // TODO: snapshot more messages diff --git a/server/tests/output/identity-login-request.bin b/server/tests/output/identity-login-request.bin new file mode 100644 index 0000000000000000000000000000000000000000..cde4443c819a3da0d127bcab6002ec2c49d0b9d4 GIT binary patch literal 199 zcmZQzVCG<8V5rK^*R4t|%4diIa*B&HbW>A|Oic|f6&!)U+1Rft(b+I3%Ei;rFT~Xp zBSu0gmKBm2m==}hZs?sDVh~W`=Hg{&obHhGxk2up_Kt1~P%x14#x30X{Irzz}fl4kL*9ggb-@Dg*#p_bWdD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ffb4e3d22da4930a3a0c866742dada0c92b8df55 GIT binary patch literal 50 zcmZQzVB}z6V5rK^*R4t|%4g;WauSmg^HTEjbQ6;@b23XRxxhj>sX%^yX$d0$CA$qa literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9f90f5cc37a736f62ed2fe4bd4d3d66be73521da GIT binary patch literal 49 ycmZQzVB}z6V5rK^*R4t|%4gvRauSmg^HTEjbQ6;@b23XRxxhj>sX%^yX$b%rrwuj$ literal 0 HcmV?d00001 diff --git a/server/tests/output/session-start-request.bin b/server/tests/output/session-start-request.bin new file mode 100644 index 0000000000000000000000000000000000000000..af0a9096cb1bdb0f8098d29dc6cf7290c1635a76 GIT binary patch literal 35 ocmZQzVB}z6V5rK^*R4t|%4gsQauSmg^HTEjbQ6;@b23XR0f5&DOaK4? literal 0 HcmV?d00001 diff --git a/server/tests/output/session-stop-request.bin b/server/tests/output/session-stop-request.bin new file mode 100644 index 0000000000000000000000000000000000000000..635a8a02ab6abdbdb06f9f145b0cd184929b8218 GIT binary patch literal 35 ocmZQzVB}z6V5rK^*R4t|%4g&UauSmg^HTEjbQ6;@b23XR0f6fXO#lD@ literal 0 HcmV?d00001 From 219d42d91bb271740383298ecb4c517be7f22c2b Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sun, 9 Aug 2026 19:55:24 +0000 Subject: [PATCH 17/43] Speed up the test suite --- .github/workflows/test.yml | 11 ++++++++--- server/tests/link.rs | 7 +++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2f25094..8bedb0b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/server/tests/link.rs b/server/tests/link.rs index 62978cc..e3fbc02 100644 --- a/server/tests/link.rs +++ b/server/tests/link.rs @@ -82,7 +82,13 @@ impl Drop for TestNet { } } +// 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( @@ -94,6 +100,7 @@ async fn conformance() { } #[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; From 7755bfa0be777521bdc7be6cbbae7c215c50ed8c Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sun, 9 Aug 2026 22:16:10 +0000 Subject: [PATCH 18/43] Serve the proxy over platform-identity TLS The proxy terminates TLS with the sled's platform identity, so the client verifies against baked-in platform PKI roots: no TOFU, no per-rack cert distribution, and identity survives reboot. The switch zone can't sign every handshake with the RoT, and delegating the IPCC device into the zone may not be acceptable, so sled-agent mints an ephemeral key and has the RoT sign its certificate once at startup; the proxy then terminates TLS with that key locally. The client's verifier accepts a chain that is the platform identity itself, or an ephemeral leaf the platform identity signed in the RoT's convention: Ed25519 over the SHA3-256 digest of the TBS certificate. --- Cargo.lock | 11 ++ Cargo.toml | 9 +- client/Cargo.toml | 4 + client/src/commands.rs | 29 ++++- client/src/lib.rs | 1 + client/src/repl.rs | 1 + client/src/tls.rs | 128 ++++++++++++++++++++ common/src/keys.rs | 47 ++++++++ server/Cargo.toml | 2 + server/src/proxy.rs | 40 ++++++- tests/Cargo.toml | 5 + tests/src/integration_tests.rs | 207 ++++++++++++++++++++++++++++++++- tests/src/test_utils.rs | 15 +++ 13 files changed, 486 insertions(+), 13 deletions(-) create mode 100644 client/src/tls.rs diff --git a/Cargo.lock b/Cargo.lock index 79e6c30..34ae644 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4800,12 +4800,16 @@ dependencies = [ "rand 0.8.5", "reqwest 0.13.4", "rustix", + "rustls 0.23.43", "rustyline", "serde", "serde_json", + "sha3", "shlex 1.3.0", "signature", "sled-hardware-types", + "slog", + "sprockets-tls", "ssh-key", "sush-api", "sush-common", @@ -4882,6 +4886,7 @@ dependencies = [ "rand_core 0.6.4", "rumors", "rustix", + "rustls 0.23.43", "schemars 0.8.22", "serde", "serde_json", @@ -4897,6 +4902,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "tokio-rustls 0.26.4", "tokio-stream", "tokio-tungstenite", "tokio-util", @@ -4909,17 +4915,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", diff --git a/Cargo.toml b/Cargo.toml index 7a077d1..b816b00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive", "env", "wrap_help"] } crypto-bigint = "0.5" dropshot = "0.17" -ed25519-dalek = { version = "2", features = ["rand_core"] } +ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] } function_name = "0.3" futures = "0.3" http = "1" @@ -28,7 +28,7 @@ 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" } @@ -38,15 +38,17 @@ progenitor-client = "0.14" pwd = "1" rand = "0.8" rand_core = "0.6" -reqwest = { version = "0.13", features = ["json"] } +reqwest = { version = "0.13", features = ["json", "rustls"] } rlimit = "0.10" rumors = { git = "https://github.com/oxidecomputer/rumors", rev = "66aea68b6578345275de3cf14151c4707345773f" } rustix = { version = "1", features = ["fs", "process", "pty", "termios"] } +rustls = "0.23" rustyline = "17" schemars = { version = "0.8", features = ["chrono", "preserve_order"] } 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" } @@ -58,6 +60,7 @@ 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" 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/src/commands.rs b/client/src/commands.rs index 3999bce..2b45b94 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -58,6 +58,7 @@ 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}; @@ -74,6 +75,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. @@ -152,6 +154,11 @@ pub struct GlobalArgs { #[clap(global = true)] pub url: Option, + /// PEM roots that a proxy's TLS certificate must chain to. + #[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)] @@ -499,10 +506,22 @@ 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) if !args.proxy_roots.is_empty() => { + 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)?); + } + Some(Client::new_with_client( + url, + tls::client(roots)?, + ctx.authz_signer(), + )) + } + Some(url) => Some(Client::new(url, ctx.authz_signer())), + None => None, + }; match (self, client) { (ClientCommand::Cert { command }, Some(client)) => cert(ctx, &client, command).await, @@ -1535,6 +1554,8 @@ pub enum CommandError { 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}")] 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/repl.rs b/client/src/repl.rs index 417cde4..8afe8ef 100644 --- a/client/src/repl.rs +++ b/client/src/repl.rs @@ -164,6 +164,7 @@ impl CommandContext for Repl { offline, ssh_auth_sock, ssh_key_id, + proxy_roots: _, } = args; if json { output = Some(OutputFormat::Json); diff --git a/client/src/tls.rs b/client/src/tls.rs new file mode 100644 index 0000000..4bc2280 --- /dev/null +++ b/client/src/tls.rs @@ -0,0 +1,128 @@ +// 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. + +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 _, Encode as _}; + +/// The same request timeout as the generated default client. +const TIMEOUT: Duration = Duration::from_secs(600); + +#[derive(Debug, Error)] +pub enum ProxyTlsError { + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + #[error("TLS configuration: {0}")] + Rustls(#[from] rustls::Error), + #[error("platform identity verifier: {0}")] + Verifier(String), +} + +/// 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/keys.rs b/common/src/keys.rs index 46b09b8..d26b2ae 100644 --- a/common/src/keys.rs +++ b/common/src/keys.rs @@ -23,6 +23,7 @@ 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; @@ -784,6 +785,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, @@ -821,6 +857,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() } @@ -956,6 +1001,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")] diff --git a/server/Cargo.toml b/server/Cargo.toml index d27bbb3..6787f3c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -40,6 +40,7 @@ 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 @@ -54,6 +55,7 @@ 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 diff --git a/server/src/proxy.rs b/server/src/proxy.rs index 8e0d712..10a21cd 100644 --- a/server/src/proxy.rs +++ b/server/src/proxy.rs @@ -27,12 +27,16 @@ 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 tokio::io::copy_bidirectional; +use sprockets_tls::keys::{CertResolver, ResolveSetting}; +use tokio::io::{AsyncRead, AsyncWrite, copy_bidirectional}; use tokio::net::{TcpListener, TcpStream}; 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}; @@ -67,6 +71,18 @@ impl Targets { /// 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, @@ -77,11 +93,13 @@ impl ProxyServer { pub async fn start( log: &Logger, local_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), @@ -89,6 +107,7 @@ impl ProxyServer { spawn(listen( log.new(o!("component" => "proxy")), listener, + acceptor, router, shutdown.clone(), )); @@ -180,6 +199,7 @@ fn named_target(request: &Request) -> Option> { async fn listen( log: Logger, listener: TcpListener, + acceptor: Option, router: Arc, shutdown: CancellationToken, ) { @@ -189,7 +209,18 @@ async fn listen( match result { Ok((client, client_addr)) => { let log = log.new(o!("client_addr" => client_addr)); - spawn(serve(log, client, router.clone())); + 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); @@ -207,7 +238,10 @@ async fn listen( /// Serve one client connection, forwarding each request to the sled /// the router picks for it. -async fn serve(log: Logger, client: TcpStream, router: Arc) { +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(); 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 d471b29..67356e0 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -5,6 +5,8 @@ //! 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; @@ -21,18 +23,33 @@ 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; +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); @@ -147,7 +164,7 @@ async fn client_proxy_server() { cubbies: Cubbies::from([(14, test_baseboard_id())]), }); let shutdown_proxy = CancellationToken::new(); - let proxy = ProxyServer::start(&log, local_addr(), rx_targets, shutdown_proxy.clone()) + 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(); @@ -262,6 +279,190 @@ async fn client_proxy_server() { 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(), + Some(tls), + rx_targets, + shutdown_proxy.clone(), + ) + .await + .expect("can't start TLS proxy server"); + let url = format!("https://{}", proxy.local_addr()); + + // 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_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") + }; + 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?"); + + // 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"); +} + async fn next_data_message(stream: &mut WebSocketStream) -> Bytes where S: AsyncRead + AsyncWrite + Unpin, diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index f2fc3c9..7fb2d4e 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -8,12 +8,14 @@ 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 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_util::sync::CancellationToken; use x509_cert::name::Name; @@ -116,6 +118,19 @@ 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 challenge = Challenge::new(nonce.clone()); From 1fcdea7df7203f354db112a0172005a32fe53341 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 10 Aug 2026 00:47:23 +0000 Subject: [PATCH 19/43] Follow the rack's cubby map Job requests may name their targets by cubby, and each server checks signed targets against its cubby map, which until now was always empty. The job manager now takes a watch channel carrying the map, which sled-agent will feed from MGS. The map is a rack fact rather than universe state, so it survives gossip migrations. --- server/src/main.rs | 4 ++ server/src/manager.rs | 7 +++ server/src/state.rs | 17 +++++- server/tests/distributed.rs | 3 + tests/src/manager_tests.rs | 107 +++++++++++++++++++++++++++++++++++- tests/src/test_utils.rs | 25 ++++++++- 6 files changed, 157 insertions(+), 6 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index 2e7e6ab..2cc8cd5 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -12,12 +12,14 @@ use clap::Parser; use dropshot::{ConfigDropshot, ConfigLogging, ConfigLoggingLevel, HandlerTaskMode, ServerBuilder}; 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; @@ -100,11 +102,13 @@ async fn main() -> Result<(), String> { let roots = builtin_root_certs()?; let shutdown = listen_for_shutdown()?; + let (_cubbies, cubbies) = watch::channel(Cubbies::new()); let mut mgr = JobManager::with_root_certs( log.clone(), path_isolation, JobOutputDir::fixed(directory), baseboard, + cubbies, gossip, &roots, shutdown.clone(), diff --git a/server/src/manager.rs b/server/src/manager.rs index 8065277..44563d5 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -32,6 +32,7 @@ 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; @@ -90,11 +91,13 @@ 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: JobOutputDir, own_baseboard: BaseboardId, + cubbies: watch::Receiver, universe: watch::Receiver, roots: &[impl AsRef], shutdown: CancellationToken, @@ -105,6 +108,7 @@ impl JobManager { path_isolation, output_dir, own_baseboard, + cubbies, universe, &roots, shutdown, @@ -112,11 +116,13 @@ impl JobManager { .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, @@ -129,6 +135,7 @@ impl JobManager { output_dir.clone(), own_baseboard.clone(), requests, + cubbies, universe, roots, shutdown, diff --git a/server/src/state.rs b/server/src/state.rs index 5892316..b3fec2e 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -847,6 +847,8 @@ impl StateManager { /// 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, @@ -854,6 +856,7 @@ impl StateManager { output_dir: JobOutputDir, own_baseboard: BaseboardId, mut requests: R, + mut cubbies: watch::Receiver, universe: watch::Receiver, roots: &[Certificate], shutdown: CancellationToken, @@ -862,7 +865,9 @@ impl StateManager { 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 @@ -970,6 +975,13 @@ 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 { @@ -994,7 +1006,8 @@ impl StateManager { // 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") + .expect("roots validated at startup"); + state.cubbies = cubbies.borrow().clone(); }); *rumors = fresh; } diff --git a/server/tests/distributed.rs b/server/tests/distributed.rs index d0878fc..bf119ee 100644 --- a/server/tests/distributed.rs +++ b/server/tests/distributed.rs @@ -19,6 +19,7 @@ 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; @@ -65,11 +66,13 @@ impl Sled { 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(), diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index 484dfba..be767bc 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -30,6 +30,7 @@ use sush_common::jobs::{ ProcessError, Session, SessionId, }; 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, IdentityRequest, Message, Request, SessionRequest}; @@ -38,7 +39,7 @@ use sush_server::{JobError, JobManager, seed_gossip}; use crate::test_utils::{ IntoBytes as _, SignJobRequest as _, ephemeral_test_root, ephemeral_test_subject, - fake_identity, manager_and_test_root, manager_login, manager_test_root_and_peer, + 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; @@ -429,6 +430,103 @@ async fn job_output_perms() { assert_eq!(mode(&out.job_output_path(&job_id, Stderr)).await, 0o600); } +#[named] +#[tokio::test] +async fn cubby_targets() { + // A cubby target only matches through the rack's cubby map. + let log = test_logger(function_name!()); + 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 mut session = Session::new(session_id.clone()); + mgr.session_start(&authn, session_id.clone(), 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() { @@ -446,6 +544,7 @@ async fn root_certs_from_files() { PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), test_baseboard_id(), + no_cubbies(), isolated(seed_gossip()), &[path], CancellationToken::new(), @@ -491,6 +590,7 @@ async fn bad_root_cert_files() { PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), test_baseboard_id(), + no_cubbies(), isolated(seed_gossip()), &[path], CancellationToken::new(), @@ -520,6 +620,7 @@ async fn job_output_dir_moves() { PathIsolation::InsecureDisable, JobOutputDir::new(rx_dirs), test_baseboard_id(), + no_cubbies(), isolated(seed_gossip()), &[root.cert().to_owned()], CancellationToken::new(), @@ -612,6 +713,7 @@ async fn universe_swap() { PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), test_baseboard_id(), + no_cubbies(), universe_rx, &[root.cert().to_owned()], CancellationToken::new(), @@ -745,6 +847,7 @@ async fn cert_chain() { PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), baseboard, + no_cubbies(), gossip, &roots, shutdown, @@ -1337,6 +1440,7 @@ async fn hostile_imports_cannot_displace() { PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), baseboard, + no_cubbies(), isolated(seed), from_ref(&root_cert), shutdown, @@ -1481,6 +1585,7 @@ async fn homonym_issuer_resolves_to_true_parent() { PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), baseboard, + no_cubbies(), isolated(seed), from_ref(&root_cert), shutdown, diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index 7fb2d4e..448f305 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -17,6 +17,7 @@ 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; @@ -27,7 +28,7 @@ use sush_common::authn::{Challenge, ChallengeResponse, Credentials, Identity, No use sush_common::codephrases::generate_id; use sush_common::jobs::{JobId, JobStartRequest, VerifiedJob}; use sush_common::keys::{EphemeralKey, KeyType, Signer}; -use sush_common::targets::Target; +use sush_common::targets::{Cubbies, Target}; use sush_server::executor::PathIsolation; use sush_server::gossip::isolated; use sush_server::output::{JobOutputDir, JobOutputFileStream}; @@ -70,21 +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::All, + target, )) .await .expect("failed to sign job") @@ -194,6 +207,7 @@ pub async fn manager_test_root_and_peer( PathIsolation::InsecureDisable, JobOutputDir::fixed(dir.path()), test_baseboard_id(), + no_cubbies(), gossip, &[root.cert().to_owned()], shutdown.clone(), @@ -203,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, From 64aa8846a90fd3186e8bfa712b16c6927f7af5df Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 10 Aug 2026 01:59:05 +0000 Subject: [PATCH 20/43] Bake the platform identity roots into the client The client ships the same idcerts the sled OS does, and verifies a proxy's TLS certificate against them by default. The --proxy-root flag replaces the baked-in roots, e.g., for a test PKI. --- client/certs/production.pem | 16 ++++++++++++++++ client/certs/staging.pem | 16 ++++++++++++++++ client/src/commands.rs | 21 +++++++++++++-------- client/src/tls.rs | 19 ++++++++++++++++++- 4 files changed, 63 insertions(+), 9 deletions(-) create mode 100644 client/certs/production.pem create mode 100644 client/certs/staging.pem 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/commands.rs b/client/src/commands.rs index 2b45b94..c823874 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -154,7 +154,8 @@ pub struct GlobalArgs { #[clap(global = true)] pub url: Option, - /// PEM roots that a proxy's TLS certificate must chain to. + /// 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, @@ -507,19 +508,23 @@ impl ClientCommand { } let client = match args.url.as_ref() { - Some(url) if !args.proxy_roots.is_empty() => { - 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)?); - } + 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(), )) } - Some(url) => Some(Client::new(url, ctx.authz_signer())), None => None, }; match (self, client) { diff --git a/client/src/tls.rs b/client/src/tls.rs index 4bc2280..f93a161 100644 --- a/client/src/tls.rs +++ b/client/src/tls.rs @@ -18,13 +18,22 @@ use slog::{Discard, Logger, o}; use sprockets_tls::keys::RotCertVerifier; use thiserror::Error; use x509_cert::Certificate; -use x509_cert::der::{Decode as _, Encode as _}; +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}")] @@ -33,6 +42,14 @@ pub enum ProxyTlsError { 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 { From e2d99b5a78c48ad2a91b2c135f36e2ac6b887fb9 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 10 Aug 2026 02:10:01 +0000 Subject: [PATCH 21/43] Document per-sled bound-request replay --- common/src/authn.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/common/src/authn.rs b/common/src/authn.rs index 4805669..3ec3cdb 100644 --- a/common/src/authn.rs +++ b/common/src/authn.rs @@ -378,6 +378,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, From 9603f30714169f01d52d0e0ba38791eef5cb9e02 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 10 Aug 2026 03:16:50 +0000 Subject: [PATCH 22/43] Document the proxy TLS posture --- client/src/tls.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/src/tls.rs b/client/src/tls.rs index f93a161..3899e6e 100644 --- a/client/src/tls.rs +++ b/client/src/tls.rs @@ -4,6 +4,10 @@ //! 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; From 528177710c7c2dd32d26b88d21499c0a9fbfc7ad Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 10 Aug 2026 06:20:48 +0000 Subject: [PATCH 23/43] Set CLOEXEC on the pty after open illumos has no O_CLOEXEC for posix_openpt, so rustix offers no OpenptFlags::CLOEXEC there. Set the flag with fcntl right after open on every platform. --- server/src/pty.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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)?; From 8d8100a943d9428acb881b6cc0549667cdc35437 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 10 Aug 2026 17:13:21 +0000 Subject: [PATCH 24/43] Drop libipcc from the client sprockets-tls now gates IPCC behind a default-on feature. Nothing in this workspace uses it, so opt out: the client binary no longer needs libipcc.so.1, which only exists on Oxide sleds. The embedded server gets the feature back through sled-agent's own sprockets dependency. Advancing sprockets pulls a newer dice-util, whose requirements cascade far enough that the lock is regenerated wholesale. --- Cargo.lock | 1887 +++++++++++++++++++++++----------------------------- Cargo.toml | 4 +- 2 files changed, 849 insertions(+), 1042 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34ae644..9b51568 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -31,9 +31,9 @@ dependencies = [ [[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", ] @@ -46,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", @@ -70,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", ] @@ -105,9 +105,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "array-init" @@ -126,9 +126,9 @@ 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" @@ -177,13 +177,13 @@ dependencies = [ [[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.119", + "syn 3.0.3", ] [[package]] @@ -217,17 +217,17 @@ dependencies = [ "serde_with", "sha3", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "attest-data" version = "0.5.0" -source = "git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd#6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd" +source = "git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d#4a39ef08d81e5177edee0bddb1146032aa21074d" dependencies = [ "const-oid 0.9.6", "der", - "getrandom 0.3.4", + "getrandom 0.4.3", "hex", "hubpack", "rats-corim", @@ -236,7 +236,7 @@ dependencies = [ "serde_with", "sha3", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -250,16 +250,28 @@ dependencies = [ "hex", "hubpack", "knuffel", - "miette", + "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 = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-credential-types" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] [[package]] name = "aws-lc-rs" @@ -284,6 +296,95 @@ dependencies = [ "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 = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] + +[[package]] +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" @@ -314,12 +415,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -332,11 +427,21 @@ 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" @@ -350,27 +455,27 @@ dependencies = [ "dsi-bitstream", "static_assertions", "suanpan", - "thiserror 2.0.18", + "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.119", + "syn 3.0.3", ] [[package]] @@ -381,15 +486,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +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", @@ -399,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", ] @@ -432,9 +538,9 @@ dependencies = [ [[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", @@ -443,9 +549,9 @@ 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", @@ -465,9 +571,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "byteorder" @@ -477,15 +583,25 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytes-utils" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +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" @@ -516,9 +632,9 @@ 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 = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -593,9 +709,9 @@ dependencies = [ [[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", @@ -612,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 0.4.3", + "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 0.5.0", "proc-macro2", "quote", - "syn 2.0.119", + "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" @@ -665,14 +781,14 @@ 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 = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -703,13 +819,12 @@ 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 0.2.2", "windows-sys 0.61.2", ] @@ -728,9 +843,9 @@ 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" @@ -784,9 +899,9 @@ dependencies = [ [[package]] name = "crc-any" -version = "2.5.0" +version = "2.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62ec9ff5f7965e4d7280bd5482acd20aadb50d632cf6c1d74493856b011fa73" +checksum = "46db9f663dfb869b80fcf59e32d7a80fc6c464a4f6328f3f06a00f5e36d05f8c" [[package]] name = "crc32fast" @@ -805,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", @@ -824,18 +939,18 @@ 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 = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -903,9 +1018,9 @@ dependencies = [ [[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", @@ -916,13 +1031,13 @@ dependencies = [ [[package]] name = "daft-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91850c0efee1e6dffcea4e5841ff3a71db80575a79f933d0c77e41c6fe0973d1" +checksum = "c839348835bc8a59714d79797f2f1bca237ca043e3d2d5ce4e727606396fb834" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -980,9 +1095,9 @@ dependencies = [ [[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" @@ -990,6 +1105,37 @@ 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" @@ -1026,7 +1172,7 @@ dependencies = [ [[package]] name = "dice-mfg-msgs" version = "0.3.0" -source = "git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd#6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd" +source = "git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d#4a39ef08d81e5177edee0bddb1146032aa21074d" dependencies = [ "const-oid 0.9.6", "corncobs", @@ -1034,7 +1180,7 @@ dependencies = [ "hubpack", "serde", "serde-big-array", - "thiserror 2.0.18", + "thiserror 2.0.20", "x509-cert", "zerocopy", ] @@ -1042,29 +1188,34 @@ dependencies = [ [[package]] name = "dice-util-barcode" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd#6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd" +source = "git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d#4a39ef08d81e5177edee0bddb1146032aa21074d" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "dice-verifier" version = "0.3.0-pre0" -source = "git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd#6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd" +source = "git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d#4a39ef08d81e5177edee0bddb1146032aa21074d" dependencies = [ - "attest-data 0.5.0 (git+https://github.com/oxidecomputer/dice-util?rev=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd)", + "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", - "libipcc 0.1.0 (git+https://github.com/oxidecomputer/ipcc-rs?rev=dbaad520e1f5ae32c10db16ce176f9c24de95652)", "log", "p384", + "pki-playground 0.2.0 (git+https://github.com/oxidecomputer/pki-playground?rev=bcd3bf2dfe36468c494eac17463a4e10be366b35)", "rats-corim", "sha3", + "slog", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", + "tokio", "x509-cert", ] @@ -1093,13 +1244,13 @@ dependencies = [ [[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.119", + "syn 3.0.3", ] [[package]] @@ -1122,15 +1273,15 @@ dependencies = [ "hostname 0.4.2", "http 1.5.0", "http-body-util", - "hyper 1.11.0", + "hyper", "hyper-util", "indexmap 2.14.0", "multer", "openapiv3", "paste", "percent-encoding", - "rustls 0.23.43", - "rustls-pemfile 2.2.0", + "rustls", + "rustls-pemfile", "schemars 0.8.22", "scopeguard", "semver", @@ -1144,9 +1295,9 @@ dependencies = [ "slog-bunyan", "slog-json", "slog-term", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "toml 1.1.4+spec-1.1.0", "uuid", @@ -1232,9 +1383,9 @@ dependencies = [ [[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" @@ -1292,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", @@ -1344,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" @@ -1356,7 +1508,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1409,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" @@ -1561,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", @@ -1615,25 +1752,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap 2.14.0", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "h2" version = "0.4.15" @@ -1702,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", @@ -1835,9 +1953,9 @@ 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.5.0", @@ -1845,14 +1963,14 @@ dependencies = [ [[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.5.0", - "http-body 1.0.1", + "http-body 1.1.0", "pin-project-lite", ] @@ -1897,9 +2015,9 @@ dependencies = [ [[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 = "hybrid-array" @@ -1910,30 +2028,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -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", -] - [[package]] name = "hyper" version = "1.11.0" @@ -1944,61 +2038,30 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2 0.4.15", + "h2", "http 1.5.0", - "http-body 1.0.1", + "http-body 1.1.0", "httparse", "httpdate", "itoa", "pin-project-lite", - "smallvec 1.15.1", + "smallvec 1.15.2", "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", - "tokio", - "tokio-rustls 0.24.1", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.5.0", - "hyper 1.11.0", - "hyper-util", - "rustls 0.23.43", - "rustls-pki-types", - "tokio", - "tokio-rustls 0.26.4", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.11.0", + "hyper", "hyper-util", - "native-tls", + "rustls", "tokio", - "tokio-native-tls", + "tokio-rustls", "tower-service", ] @@ -2013,14 +2076,14 @@ dependencies = [ "futures-channel", "futures-util", "http 1.5.0", - "http-body 1.0.1", - "hyper 1.11.0", + "http-body 1.1.0", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", - "system-configuration 0.7.0", + "socket2", + "system-configuration", "tokio", "tower-layer", "tower-service", @@ -2030,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", @@ -2054,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", @@ -2067,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", @@ -2080,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", @@ -2114,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", @@ -2146,15 +2210,15 @@ 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", @@ -2178,16 +2242,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "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", @@ -2207,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" @@ -2218,18 +2282,8 @@ 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" +name = "is-terminal" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ @@ -2261,9 +2315,62 @@ 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 = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +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" @@ -2277,7 +2384,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2326,11 +2433,12 @@ dependencies = [ [[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", ] @@ -2352,7 +2460,7 @@ dependencies = [ "base64 0.21.7", "chumsky", "knuffel-derive", - "miette", + "miette 5.10.0", "thiserror 1.0.69", "unicode-width 0.1.14", ] @@ -2370,6 +2478,33 @@ dependencies = [ "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" @@ -2385,43 +2520,23 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" -[[package]] -name = "libipcc" -version = "0.1.0" -source = "git+https://github.com/oxidecomputer/ipcc-rs?rev=524eb8f125003dff50b9703900c6b323f00f9e1b#524eb8f125003dff50b9703900c6b323f00f9e1b" -dependencies = [ - "cfg-if", - "libc", - "thiserror 1.0.69", -] - -[[package]] -name = "libipcc" -version = "0.1.0" -source = "git+https://github.com/oxidecomputer/ipcc-rs?rev=dbaad520e1f5ae32c10db16ce176f9c24de95652#dbaad520e1f5ae32c10db16ce176f9c24de95652" -dependencies = [ - "cfg-if", - "libc", - "thiserror 1.0.69", -] - [[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" @@ -2434,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", @@ -2452,7 +2567,7 @@ 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 0.9.6", @@ -2469,18 +2584,18 @@ 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]] @@ -2503,15 +2618,15 @@ 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", ] @@ -2525,18 +2640,37 @@ dependencies = [ "backtrace", "backtrace-ext", "is-terminal", - "miette-derive", + "miette-derive 5.10.0", "once_cell", - "owo-colors", - "supports-color", - "supports-hyperlinks", - "supports-unicode", + "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", + "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" @@ -2548,6 +2682,17 @@ dependencies = [ "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" @@ -2592,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 0.1.6", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - [[package]] name = "natord" version = "1.0.9" @@ -2617,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", ] @@ -2630,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]] @@ -2639,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", @@ -2662,9 +2790,9 @@ 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", ] @@ -2685,20 +2813,19 @@ 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.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" +checksum = "bd8e500409e6cd603b03e477c26a6caecdc27ac58979a53e881c75eafc079f44" [[package]] name = "num-primitive" @@ -2727,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", @@ -2745,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" @@ -2762,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" @@ -2783,38 +2919,6 @@ dependencies = [ "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-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -2822,16 +2926,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "openssl-sys" -version = "0.9.111" +name = "outref" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "owo-colors" @@ -2839,6 +2937,12 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "oxnet" version = "0.1.6" @@ -2930,7 +3034,7 @@ dependencies = [ "cfg-if", "libc", "redox_syscall", - "smallvec 1.15.1", + "smallvec 1.15.2", "windows-link", ] @@ -2958,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", @@ -2968,16 +3072,18 @@ dependencies = [ "clap-num", "der", "ed25519-dalek", + "futures", "hex", "lpc55_sign", "natord", "oauth2", + "oauth2-reqwest", "once_cell", "permission-slip-common", - "progenitor 0.11.2", - "progenitor-client 0.11.2", + "progenitor", + "progenitor-client", "rand_core 0.6.4", - "reqwest 0.12.24", + "reqwest", "serde", "serde_json", "ssh-key", @@ -2990,22 +3096,30 @@ 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", + "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 = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkcs1" @@ -3030,9 +3144,9 @@ 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 = "pki-playground" @@ -3049,13 +3163,43 @@ dependencies = [ "hex", "ipnet", "knuffel", - "miette", + "miette 5.10.0", "p384", "pem-rfc7468", "pkcs8", - "rand 0.8.5", + "rand 0.8.7", "rsa", - "sha1 0.10.6", + "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", @@ -3066,9 +3210,18 @@ dependencies = [ [[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" @@ -3085,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", ] @@ -3118,11 +3271,11 @@ 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 = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.23.9", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] @@ -3150,49 +3303,45 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "proc-macro-error-attr2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" dependencies = [ - "unicode-ident", + "proc-macro2", + "quote", ] [[package]] -name = "progenitor" -version = "0.11.2" +name = "proc-macro-error2" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2326f73d5326257514712436680ef8da4543ee47c0e9e0d501545c8909ee12e4" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" dependencies = [ - "progenitor-client 0.11.2", - "progenitor-impl 0.11.2", - "progenitor-macro 0.11.2", + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "progenitor" -version = "0.14.0" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8ba1d77160e6d5c95bdf0792527f76bf528791093fa83015bc2908a0ba9d076" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "progenitor-client 0.14.0", - "progenitor-impl 0.14.0", - "progenitor-macro 0.14.0", + "unicode-ident", ] [[package]] -name = "progenitor-client" -version = "0.11.2" +name = "progenitor" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71a0beb939758f229cbae70a4889c7c76a4ac0e90f0b1e7ae9b4636a927d1018" +checksum = "d8ba1d77160e6d5c95bdf0792527f76bf528791093fa83015bc2908a0ba9d076" dependencies = [ - "bytes", - "futures-core", - "percent-encoding", - "reqwest 0.12.24", - "serde", - "serde_json", - "serde_urlencoded", + "progenitor-client", + "progenitor-impl", + "progenitor-macro", ] [[package]] @@ -3204,34 +3353,12 @@ dependencies = [ "bytes", "futures-core", "percent-encoding", - "reqwest 0.13.4", + "reqwest", "serde", "serde_json", "serde_urlencoded", ] -[[package]] -name = "progenitor-impl" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f6d9109b04e005bbdec84cacec7e81cc15533f2b5dc505f0defc212d270c15" -dependencies = [ - "heck 0.5.0", - "http 1.5.0", - "indexmap 2.14.0", - "openapiv3", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "serde", - "serde_json", - "syn 2.0.119", - "thiserror 2.0.18", - "typify 0.4.3", - "unicode-ident", -] - [[package]] name = "progenitor-impl" version = "0.14.0" @@ -3249,29 +3376,11 @@ dependencies = [ "serde", "serde_json", "syn 2.0.119", - "thiserror 2.0.18", - "typify 0.6.2", + "thiserror 2.0.20", + "typify", "unicode-ident", ] -[[package]] -name = "progenitor-macro" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46596c574831739c661f22923fe587399c61f5e3e79b73cc9a93644c72248d84" -dependencies = [ - "openapiv3", - "proc-macro2", - "progenitor-impl 0.11.2", - "quote", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_tokenstream", - "serde_yaml", - "syn 2.0.119", -] - [[package]] name = "progenitor-macro" version = "0.14.0" @@ -3280,7 +3389,7 @@ checksum = "efa969a1349979c5f64347f204e794781a86d738206d75672e6c9493f5910002" dependencies = [ "openapiv3", "proc-macro2", - "progenitor-impl 0.14.0", + "progenitor-impl", "quote", "schemars 0.8.22", "serde", @@ -3312,9 +3421,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.43", - "socket2 0.5.10", - "thiserror 2.0.18", + "rustls", + "socket2", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -3334,10 +3443,10 @@ dependencies = [ "rand_pcg", "ring", "rustc-hash", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -3352,9 +3461,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3396,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", @@ -3407,9 +3516,9 @@ 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", @@ -3452,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]] @@ -3491,7 +3600,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3510,7 +3619,7 @@ 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]] @@ -3562,16 +3671,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "regress" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" -dependencies = [ - "hashbrown 0.16.1", - "memchr", -] - [[package]] name = "regress" version = "0.11.1" @@ -3582,90 +3681,6 @@ dependencies = [ "memchr", ] -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.4.15", - "http 1.5.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.11.0", - "hyper-rustls 0.27.7", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "tokio", - "tokio-native-tls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams 0.4.2", - "web-sys", -] - [[package]] name = "reqwest" version = "0.13.4" @@ -3677,12 +3692,12 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.15", + "h2", "http 1.5.0", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", - "hyper 1.11.0", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", @@ -3690,15 +3705,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.43", + "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3706,7 +3721,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wasm-streams", "web-sys", ] @@ -3728,7 +3743,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -3745,9 +3760,9 @@ 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 0.9.6", "digest 0.10.7", @@ -3779,11 +3794,11 @@ dependencies = [ "futures-util", "hex", "itertools", - "rand 0.8.5", + "rand 0.8.7", "seq-macro", - "smallvec 1.15.1", + "smallvec 1.15.2", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tokio", "tokio-stream", @@ -3812,29 +3827,17 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.43" @@ -3845,7 +3848,7 @@ dependencies = [ "log", "once_cell", "rustls-pki-types", - "rustls-webpki 0.103.8", + "rustls-webpki", "subtle", "zeroize", ] @@ -3856,19 +3859,10 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", + "security-framework", ] [[package]] @@ -3882,9 +3876,9 @@ 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", @@ -3901,14 +3895,14 @@ dependencies = [ "jni", "log", "once_cell", - "rustls 0.23.43", + "rustls", "rustls-native-certs", "rustls-platform-verifier-android", - "rustls-webpki 0.103.8", - "security-framework 3.5.1", + "rustls-webpki", + "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3919,19 +3913,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.101.7" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -3941,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" @@ -3951,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", @@ -3969,9 +3953,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "salty" @@ -3994,9 +3978,9 @@ dependencies = [ [[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", ] @@ -4058,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" @@ -4093,24 +4067,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4119,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", @@ -4145,9 +4106,9 @@ 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", @@ -4175,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.119", + "syn 3.0.3", ] [[package]] @@ -4272,9 +4233,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -4282,6 +4243,7 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.2.2", "serde_core", @@ -4292,9 +4254,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", @@ -4317,9 +4279,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4372,10 +4334,11 @@ 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", ] @@ -4413,9 +4376,9 @@ 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" @@ -4427,7 +4390,7 @@ dependencies = [ "schemars 0.8.22", "serde", "slog", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4522,9 +4485,9 @@ 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 = "smawk" @@ -4532,16 +4495,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.5" @@ -4554,9 +4507,9 @@ dependencies = [ [[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", ] @@ -4574,10 +4527,10 @@ dependencies = [ [[package]] name = "sprockets-tls" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/sprockets.git?rev=68a4b3bf819722f9f57a3f0c99e1393ed01ba392#68a4b3bf819722f9f57a3f0c99e1393ed01ba392" +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=6e0ef48f72ff85ba50fc8286c8e89dc5f9c822dd)", + "attest-data 0.5.0 (git+https://github.com/oxidecomputer/dice-util?rev=4a39ef08d81e5177edee0bddb1146032aa21074d)", "camino", "cfg-if", "clap", @@ -4585,9 +4538,8 @@ dependencies = [ "dice-verifier", "ed25519-dalek", "hubpack", - "libipcc 0.1.0 (git+https://github.com/oxidecomputer/ipcc-rs?rev=524eb8f125003dff50b9703900c6b323f00f9e1b)", "pem-rfc7468", - "rustls 0.23.43", + "rustls", "secrecy", "serde", "sha2", @@ -4598,7 +4550,7 @@ dependencies = [ "slog-term", "thiserror 1.0.69", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "toml 0.8.23", "x509-cert", "zeroize", @@ -4607,10 +4559,10 @@ dependencies = [ [[package]] name = "sprockets-tls-test-utils" version = "0.1.0" -source = "git+https://github.com/oxidecomputer/sprockets.git?rev=68a4b3bf819722f9f57a3f0c99e1393ed01ba392#68a4b3bf819722f9f57a3f0c99e1393ed01ba392" +source = "git+https://github.com/oxidecomputer/sprockets.git?rev=b8782742d1bd91546887fbc1757a7d024a5469ef#b8782742d1bd91546887fbc1757a7d024a5469ef" dependencies = [ "camino", - "pki-playground", + "pki-playground 0.2.0 (git+https://github.com/oxidecomputer/pki-playground?rev=7600756029ce046a02c6234aa84ce230cc5eaa04)", ] [[package]] @@ -4739,6 +4691,15 @@ dependencies = [ "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" @@ -4748,6 +4709,12 @@ dependencies = [ "is-terminal", ] +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + [[package]] name = "supports-unicode" version = "2.1.0" @@ -4757,6 +4724,12 @@ dependencies = [ "is-terminal", ] +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "sush-api" version = "0.1.0" @@ -4765,7 +4738,7 @@ dependencies = [ "chrono", "dropshot", "http-range-header", - "hyper 1.11.0", + "hyper", "schemars 0.8.22", "serde", "sled-hardware-types", @@ -4795,12 +4768,12 @@ dependencies = [ "pem-rfc7468", "permission-slip-client", "permission-slip-common", - "progenitor 0.14.0", - "progenitor-client 0.14.0", - "rand 0.8.5", - "reqwest 0.13.4", + "progenitor", + "progenitor-client", + "rand 0.8.7", + "reqwest", "rustix", - "rustls 0.23.43", + "rustls", "rustyline", "serde", "serde_json", @@ -4837,7 +4810,7 @@ dependencies = [ "http-range-header", "p256", "pem-rfc7468", - "rand 0.8.5", + "rand 0.8.7", "rand_core 0.6.4", "rlimit", "rustix", @@ -4875,7 +4848,7 @@ dependencies = [ "http 1.5.0", "http-body-util", "http-range-header", - "hyper 1.11.0", + "hyper", "hyper-util", "libc", "lru", @@ -4886,7 +4859,7 @@ dependencies = [ "rand_core 0.6.4", "rumors", "rustix", - "rustls 0.23.43", + "rustls", "schemars 0.8.22", "serde", "serde_json", @@ -4902,7 +4875,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-stream", "tokio-tungstenite", "tokio-util", @@ -4975,12 +4948,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -5001,36 +4968,15 @@ dependencies = [ "syn 2.0.119", ] -[[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 0.9.4", - "system-configuration-sys 0.5.0", -] - [[package]] name = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.1", "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", + "system-configuration-sys", ] [[package]] @@ -5057,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", @@ -5070,9 +5016,9 @@ 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", ] @@ -5089,12 +5035,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.3" +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]] @@ -5108,6 +5054,16 @@ dependencies = [ "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]] name = "thiserror" version = "1.0.69" @@ -5119,11 +5075,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]] @@ -5139,20 +5095,20 @@ dependencies = [ [[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.119", + "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", ] @@ -5191,9 +5147,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", @@ -5201,9 +5157,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", ] @@ -5247,7 +5203,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -5273,41 +5229,21 @@ dependencies = [ "syn 3.0.3", ] -[[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.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.43", + "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", @@ -5328,13 +5264,14 @@ 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-sink", + "libc", "pin-project-lite", "tokio", ] @@ -5375,15 +5312,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -5404,19 +5332,19 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow 0.7.13", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.23.9" +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.14.0", - "toml_datetime 0.7.3", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 0.7.13", + "winnow 1.0.4", ] [[package]] @@ -5442,14 +5370,14 @@ 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", @@ -5457,20 +5385,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.5.0", - "http-body 1.0.1", - "iri-string", + "http-body 1.1.0", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -5487,19 +5415,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", ] @@ -5521,9 +5461,9 @@ dependencies = [ "http 1.5.0", "httparse", "log", - "rand 0.9.2", - "sha1 0.10.6", - "thiserror 2.0.18", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.20", "utf-8", ] @@ -5533,44 +5473,14 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "typify" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" -dependencies = [ - "typify-impl 0.4.3", - "typify-macro 0.4.3", -] - [[package]] name = "typify" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc0b89f47309feaeb23c4509c15c9a04234f7deccef6f96c3bfe95319819a304" dependencies = [ - "typify-impl 0.6.2", - "typify-macro 0.6.2", -] - -[[package]] -name = "typify-impl" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" -dependencies = [ - "heck 0.5.0", - "log", - "proc-macro2", - "quote", - "regress 0.10.5", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "syn 2.0.119", - "thiserror 2.0.18", - "unicode-ident", + "typify-impl", + "typify-macro", ] [[package]] @@ -5583,33 +5493,16 @@ dependencies = [ "log", "proc-macro2", "quote", - "regress 0.11.1", + "regress", "schemars 0.8.22", "semver", "serde", "serde_json", "syn 2.0.119", - "thiserror 2.0.18", + "thiserror 2.0.20", "unicode-ident", ] -[[package]] -name = "typify-macro" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" -dependencies = [ - "proc-macro2", - "quote", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "serde_tokenstream", - "syn 2.0.119", - "typify-impl 0.4.3", -] - [[package]] name = "typify-macro" version = "0.6.2" @@ -5624,7 +5517,7 @@ dependencies = [ "serde_json", "serde_tokenstream", "syn 2.0.119", - "typify-impl 0.6.2", + "typify-impl", ] [[package]] @@ -5641,9 +5534,9 @@ 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 = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -5677,14 +5570,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]] @@ -5719,18 +5613,18 @@ dependencies = [ "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" @@ -5767,18 +5661,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", @@ -5789,23 +5683,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", @@ -5813,9 +5703,9 @@ 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", @@ -5826,26 +5716,13 @@ dependencies = [ [[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wasm-streams" version = "0.5.0" @@ -5861,9 +5738,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", @@ -5888,12 +5765,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - [[package]] name = "winapi" version = "0.3.9" @@ -5916,7 +5787,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5997,18 +5868,18 @@ dependencies = [ [[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", ] @@ -6031,21 +5902,6 @@ dependencies = [ "windows-link", ] -[[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", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -6079,12 +5935,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" @@ -6097,12 +5947,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" @@ -6115,12 +5959,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" @@ -6145,12 +5983,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" @@ -6163,12 +5995,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" @@ -6181,12 +6007,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" @@ -6199,12 +6019,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" @@ -6219,9 +6033,9 @@ 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", ] @@ -6231,28 +6045,21 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" - -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" 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" @@ -6298,9 +6105,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", @@ -6309,9 +6116,9 @@ 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", @@ -6321,18 +6128,18 @@ dependencies = [ [[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", @@ -6341,18 +6148,18 @@ dependencies = [ [[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", @@ -6362,18 +6169,18 @@ dependencies = [ [[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", @@ -6382,9 +6189,9 @@ dependencies = [ [[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", @@ -6393,9 +6200,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", @@ -6404,9 +6211,9 @@ 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", @@ -6415,6 +6222,6 @@ dependencies = [ [[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 b816b00..e624d78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,8 +54,8 @@ 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 = "68a4b3bf819722f9f57a3f0c99e1393ed01ba392" } -sprockets-tls-test-utils = { git = "https://github.com/oxidecomputer/sprockets.git", rev = "68a4b3bf819722f9f57a3f0c99e1393ed01ba392" } +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" From 7ab7ec18a057a654c7ca9e18bfd94bc8fb7c618f Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 10 Aug 2026 18:16:15 +0000 Subject: [PATCH 25/43] Drop schemars' preserve_order Feature unification turns it on for every schemars user in a workspace that embeds sush, which reorders the properties of every generated schema and breaks omicron's schema snapshot tests. Nothing here depends on property order. --- Cargo.lock | 1 - Cargo.toml | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b51568..14b788f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3993,7 +3993,6 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "chrono", "dyn-clone", - "indexmap 1.9.3", "schemars_derive", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index e624d78..6ba7afe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ rumors = { git = "https://github.com/oxidecomputer/rumors", rev = "66aea68b65783 rustix = { version = "1", features = ["fs", "process", "pty", "termios"] } rustls = "0.23" rustyline = "17" -schemars = { version = "0.8", features = ["chrono", "preserve_order"] } +schemars = { version = "0.8", features = ["chrono"] } serde = "1" serde_json = "1" sha2 = "0.10" From 0b0051ab4ed2d3a27d62f29b53202adc3cf90af6 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Mon, 10 Aug 2026 18:16:15 +0000 Subject: [PATCH 26/43] Keep the cancelled job unrunnable in cancel_queued_job The same race attribution had: job B could start before the cancel landed on a loaded machine, and a dependency refresh made the window easy to hit locally. Queue B behind a chain hole. --- tests/src/manager_tests.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index be767bc..eb7e53e 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -336,7 +336,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; From 30b0023d0c636ed9a6f77cc8c3e6ceb64edd27c4 Mon Sep 17 00:00:00 2001 From: Emily Albini Date: Tue, 11 Aug 2026 12:30:02 +0200 Subject: [PATCH 27/43] create new Codephrase wrapper --- common/src/codephrases.rs | 180 +++++++++++++++++++++++++++++++++++++- common/src/lib.rs | 1 + 2 files changed, 180 insertions(+), 1 deletion(-) diff --git a/common/src/codephrases.rs b/common/src/codephrases.rs index 1fac573..7b39dd3 100644 --- a/common/src/codephrases.rs +++ b/common/src/codephrases.rs @@ -9,11 +9,21 @@ //! 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::str::FromStr; + +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}; +use borsh::{BorshDeserialize, BorshSerialize}; +use std::io::prelude::{Read, Write}; /// Entropy is treated as an integer whose base is to be changed /// to 2048, which gives us indexes into the BIP-39 word list. @@ -35,6 +45,174 @@ pub const PHRASE_WORDS_ID: usize = 8; /// will also accept arbitrary ASCII whitespace as word separators. pub const WORD_SEPARATOR: &str = "-"; +const TRUNCATED_MASK: U256 = U256::from_u128(2u128.pow(88) - 1); + +/// Random code phrase like `abstract misery favorite ordinary moon talk`. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Codephrase(U256); + +impl Codephrase { + /// Generate a new random codephrase. + pub fn random() -> Self { + Self(U256::random(&mut OsRng)) + } + + pub fn truncate(self) -> Self { + Self(self.0.bitand(&TRUNCATED_MASK)) + } + + 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() + } +} + +impl std::fmt::Display for Codephrase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&codephrase(self.0).join(WORD_SEPARATOR)) + } +} + +impl std::fmt::Debug for Codephrase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Codephrase({self})") + } +} + +impl FromStr for Codephrase { + type Err = InvalidCodephrase; + + fn from_str(value: &str) -> Result { + Ok(Self(decode_phrase(value)?)) + } +} + +impl Serialize for Codephrase { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Codephrase { + fn deserialize>(deserializer: D) -> Result { + let string = ::deserialize(deserializer)?; + Ok(Self(decode_phrase(&string).map_err(D::Error::custom)?)) + } +} + +impl BorshSerialize for Codephrase { + fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + <[u8; 32] as BorshSerialize>::serialize(&self.to_be_bytes(), writer) + } +} + +impl BorshDeserialize for Codephrase { + fn deserialize_reader(reader: &mut R) -> std::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() -> std::borrow::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 fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}({})", stringify!($name), self.0) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> 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()?)) + } + } + } +} + +#[derive(Clone, Copy)] +pub enum CodephraseLength { + Full, + Truncated, +} + /// Decoding a phrase failed. #[derive(Debug, Error)] #[error("invalid or non-canonical code phrase")] diff --git a/common/src/lib.rs b/common/src/lib.rs index a88bc26..da9e5b3 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -6,6 +6,7 @@ pub mod authn; pub mod borsh; +#[macro_use] pub mod codephrases; pub mod interactive; pub mod jobs; From 2dfc110ef8943018ad7a0ec41469f9dc49023a0a Mon Sep 17 00:00:00 2001 From: Emily Albini Date: Tue, 11 Aug 2026 12:51:51 +0200 Subject: [PATCH 28/43] switch auth nonces to the newtype --- common/src/authn.rs | 88 +++++++----------- common/src/keys.rs | 8 +- server/src/manager.rs | 2 +- server/src/messages.rs | 10 +- server/tests/common/mod.rs | 2 +- .../tests/output/identity-login-request.bin | Bin 199 -> 241 bytes tests/src/manager_tests.rs | 5 +- tests/src/test_utils.rs | 2 +- 8 files changed, 49 insertions(+), 68 deletions(-) diff --git a/common/src/authn.rs b/common/src/authn.rs index 3ec3cdb..6a1be55 100644 --- a/common/src/authn.rs +++ b/common/src/authn.rs @@ -41,9 +41,8 @@ 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, WORD_SEPARATOR, codephrase, decode_phrase}; use crate::keys::{EncodedSignature, KeyError, KeyId, Signed, SshPublicKey, ToBeSigned, Verified}; /// The name of our custom HTTP authentication scheme. @@ -63,43 +62,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( - BorshDeserialize, - BorshSerialize, - 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. @@ -135,7 +113,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))) } @@ -291,7 +269,7 @@ impl BoundRequest { } = self; hash(Self::TYPE_NAME); hash(key_id.as_bytes()); - hash(nonce.as_bytes()); + hash(&nonce.to_be_bytes()); hash(method.as_bytes()); hash(target.as_bytes()); hash(&seq.to_be_bytes()); @@ -334,7 +312,7 @@ impl FromStr for BoundCredentials { let mut params = AuthnParams::from_str(s)?; let creds = Self { key_id: params.get("key-id")?, - nonce: params.get("nonce")?, + nonce: params.parse("nonce")?, seq: params.parse("seq")?, signature: EncodedSignature { r: params.get("r")?, @@ -432,7 +410,7 @@ impl ChallengeResponse { pub fn new(challenge: Challenge, epk: RequestVerifier) -> Self { Self { nonce: challenge.nonce, - cnonce: Nonce::generate(), + cnonce: Nonce::random(), epk, } } @@ -453,8 +431,8 @@ impl ToBeSigned for ChallengeResponse { let Self { nonce, cnonce, epk } = self; hash(Self::TYPE_NAME); - hash(nonce.as_bytes()); - hash(cnonce.as_bytes()); + hash(&nonce.to_be_bytes()); + hash(&cnonce.to_be_bytes()); hash(epk.as_bytes()); hasher.finalize().as_bytes().to_vec() } @@ -543,8 +521,8 @@ 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")?, + nonce: params.parse("nonce")?, + cnonce: params.parse("cnonce")?, key_id: params.get("key-id")?, epk: params.parse("epk")?, signature: EncodedSignature { @@ -697,7 +675,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}")); @@ -752,7 +730,7 @@ 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("baz".to_string()), Nonce::random(), &request); assert_eq!( creds.to_string().parse::().unwrap(), creds @@ -786,7 +764,7 @@ mod test { }; assert!(verifier.verify(&request, &relabeled).is_err()); let relabeled = BoundCredentials { - nonce: Nonce::generate(), + nonce: Nonce::random(), ..creds }; assert!(verifier.verify(&request, &relabeled).is_err()); @@ -851,27 +829,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=baz,epk=plugh,r=r,s=s,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=baz,epk={epk},r=r,s=s,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=baz,epk={epk},r=r,s=s,flags=0,counter=0,foo=bar" )) .unwrap_err(), AuthnError::TooManyParams diff --git a/common/src/keys.rs b/common/src/keys.rs index d26b2ae..6cd2b65 100644 --- a/common/src/keys.rs +++ b/common/src/keys.rs @@ -1068,10 +1068,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(); @@ -1084,7 +1084,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/server/src/manager.rs b/server/src/manager.rs index 44563d5..70a0443 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -256,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)); }}; diff --git a/server/src/messages.rs b/server/src/messages.rs index 37a3283..d84ad1e 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -240,6 +240,8 @@ mod wire_format { use super::v0::*; use super::*; + use std::str::FromStr as _; + use sush_common::authn::Nonce; /// Serialize a message and compare against the snapshot in /// `tests/output/`, or rewrite it under `EXPECTORATE=overwrite`. @@ -350,9 +352,11 @@ mod wire_format { // Craft deterministic evidence: nonces, then the ed25519 // basepoint as the verifier. let mut evidence = Vec::new(); - for nonce in ["abandon", "ability"] { - evidence.extend((nonce.len() as u32).to_le_bytes()); - evidence.extend(nonce.as_bytes()); + 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]); diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs index 44dabd0..baacc3e 100644 --- a/server/tests/common/mod.rs +++ b/server/tests/common/mod.rs @@ -139,7 +139,7 @@ pub fn ephemeral_root() -> EphemeralKey { /// An authenticated identity for `key`, as `iam` would produce. pub async fn fake_identity(key: &mut EphemeralKey) -> Identity { - let challenge = Challenge::new(Nonce::generate()); + let challenge = Challenge::new(Nonce::random()); let response = ChallengeResponse::new(challenge, RequestKey::new().verifier()); let signed = key.sign(response).await.unwrap(); let verified = signed diff --git a/server/tests/output/identity-login-request.bin b/server/tests/output/identity-login-request.bin index cde4443c819a3da0d127bcab6002ec2c49d0b9d4..ae710271e52dfa00111a04a12650eeff9b367d8d 100644 GIT binary patch delta 84 ccmX@k_>pl!o+ATgfH7j?MB#}GSindf02p-$N&o-= delta 36 ncmey!c${%Uo;W)L14Cj`VqQvq9)y{hlUY(3F>#~p#51Y@%GwLq diff --git a/tests/src/manager_tests.rs b/tests/src/manager_tests.rs index eb7e53e..eff10d6 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -1214,7 +1214,7 @@ async fn gossiped_identities() { // A liar claims root's key with evidence signed by their own. let bogus_key = RequestKey::new(); - let bogus = ChallengeResponse::new(Challenge::new(Nonce::generate()), bogus_key.verifier()); + 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() @@ -1233,8 +1233,7 @@ async fn gossiped_identities() { // Real evidence authorizes here without ever logging in here. let request_key = RequestKey::new(); - let response = - ChallengeResponse::new(Challenge::new(Nonce::generate()), request_key.verifier()); + 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); diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index 448f305..a159447 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -145,7 +145,7 @@ pub fn test_pki(prefix: &'static str) -> (TempDir, Utf8PathBuf) { } 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(); From b4b9cc9a11b1d6b6c8dd7b26ab8c652ddaf5f2dc Mon Sep 17 00:00:00 2001 From: Emily Albini Date: Tue, 11 Aug 2026 12:59:17 +0200 Subject: [PATCH 29/43] switch session IDs to the newtype --- client/src/cli.rs | 2 +- client/src/commands.rs | 16 +- common/src/borsh.rs | 2 +- common/src/jobs.rs | 77 +++------ server/src/manager.rs | 7 +- server/src/messages.rs | 12 +- server/src/server.rs | 2 +- server/src/state.rs | 22 ++- server/tests/distributed.rs | 14 +- .../output/session-allow-attach-request.bin | Bin 50 -> 63 bytes .../output/session-deny-attach-request.bin | Bin 49 -> 62 bytes server/tests/output/session-start-request.bin | Bin 35 -> 48 bytes server/tests/output/session-stop-request.bin | Bin 35 -> 48 bytes tests/src/integration_tests.rs | 6 +- tests/src/manager_tests.rs | 157 +++++++----------- 15 files changed, 119 insertions(+), 198 deletions(-) diff --git a/client/src/cli.rs b/client/src/cli.rs index 06a95d8..76aad00 100644 --- a/client/src/cli.rs +++ b/client/src/cli.rs @@ -96,7 +96,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(); } diff --git a/client/src/commands.rs b/client/src/commands.rs index c823874..4dc1ae2 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -759,7 +759,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); } @@ -781,7 +781,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 @@ -809,7 +809,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() @@ -827,7 +827,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 @@ -843,11 +843,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)?; @@ -920,7 +916,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() diff --git a/common/src/borsh.rs b/common/src/borsh.rs index e1966a9..97e9133 100644 --- a/common/src/borsh.rs +++ b/common/src/borsh.rs @@ -109,7 +109,7 @@ mod test { 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(); + let good = borsh::to_vec(&SessionId::random().first_job_id().to_string()).unwrap(); assert!(borsh::from_slice::(&good).is_ok()); } } diff --git a/common/src/jobs.rs b/common/src/jobs.rs index 78fbf42..3fcd830 100644 --- a/common/src/jobs.rs +++ b/common/src/jobs.rs @@ -27,9 +27,7 @@ use crate::borsh::{ borsh_de_datetime, borsh_de_hash, borsh_de_job_id, borsh_de_target, borsh_ser_datetime, borsh_ser_hash, borsh_ser_target, }; -use crate::codephrases::{ - InvalidCodephrase, WORD_SEPARATOR, decode_phrase, generate_id, id_phrase, -}; +use crate::codephrases::{InvalidCodephrase, WORD_SEPARATOR, decode_phrase, id_phrase}; use crate::interactive::InteractiveJobError; use crate::keys::{KeyId, Signed, ToBeSigned, Verified}; use crate::targets::Target; @@ -110,37 +108,34 @@ impl slog::Value for JobId { } } -/// A globally unique identifier for a session. -#[derive( - BorshDeserialize, - BorshSerialize, - Clone, - Debug, - Deserialize, - Eq, - Hash, - JsonSchema, - Ord, - PartialEq, - PartialOrd, - Serialize, -)] -pub struct SessionId(String); +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; +} -#[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() + U256::from_be_slice(hash(&self.0.to_be_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::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.as_bytes()].concat()), } @@ -150,32 +145,6 @@ impl SessionId { } } -impl Deref for SessionId { - type Target = str; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl fmt::Display for SessionId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -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()) - } -} - #[derive(Clone, Debug, Default, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] pub enum LastJob { #[default] @@ -210,8 +179,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> { diff --git a/server/src/manager.rs b/server/src/manager.rs index 70a0443..3fc0ad4 100644 --- a/server/src/manager.rs +++ b/server/src/manager.rs @@ -433,7 +433,7 @@ impl JobManager { move |state| { state .session() - .is_some_and(|s| *s.session_id() == session_id) + .is_some_and(|s| s.session_id() == session_id) } } @@ -493,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(()) } diff --git a/server/src/messages.rs b/server/src/messages.rs index d84ad1e..8efac53 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -266,11 +266,15 @@ mod wire_format { 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")), + SessionRequest::Start(sid("abandon-ability")), )) .into(); assert_wire_format("session-start-request", msg); @@ -280,7 +284,7 @@ mod wire_format { fn session_stop_request() { let msg: VersionedMessage = Message::Request(Request::session( KeyId::from("zoo-zero".to_string()), - SessionRequest::Stop(SessionId::from("abandon-ability")), + SessionRequest::Stop(sid("abandon-ability")), )) .into(); assert_wire_format("session-stop-request", msg); @@ -291,7 +295,7 @@ mod wire_format { let msg: VersionedMessage = Message::Request(Request::session( KeyId::from("zoo-zero".to_string()), SessionRequest::AllowAttach( - SessionId::from("abandon-ability"), + sid("abandon-ability"), KeyId::from("able-about".to_string()), Access::ReadWrite, ), @@ -305,7 +309,7 @@ mod wire_format { let msg: VersionedMessage = Message::Request(Request::session( KeyId::from("zoo-zero".to_string()), SessionRequest::DenyAttach( - SessionId::from("abandon-ability"), + sid("abandon-ability"), KeyId::from("able-about".to_string()), ), )) diff --git a/server/src/server.rs b/server/src/server.rs index 621464a..76bbb56 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -183,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()) } diff --git a/server/src/state.rs b/server/src/state.rs index b3fec2e..79ccf66 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -173,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() } @@ -434,6 +434,7 @@ impl State { self.revoked_keys.peek(key_id).is_some() } + #[allow(clippy::result_large_err)] fn update( &mut self, log: &Logger, @@ -474,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); } @@ -494,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(), } @@ -515,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 { @@ -553,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"; @@ -570,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); @@ -592,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); @@ -610,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"; diff --git a/server/tests/distributed.rs b/server/tests/distributed.rs index bf119ee..9ee6584 100644 --- a/server/tests/distributed.rs +++ b/server/tests/distributed.rs @@ -117,16 +117,16 @@ async fn jobs_gossip_between_sleds() { // 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::new(); - let session = Session::new(session_id.clone()); + let session_id = SessionId::random(); + let session = Session::new(session_id); a.mgr - .session_start(&authn_a, session_id.clone(), true) + .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) + .is_some_and(|s| s.session_id() == session_id) }) .await; @@ -161,15 +161,15 @@ async fn jobs_gossip_between_sleds() { .await; // A session started on B supersedes A's everywhere. - let successor = SessionId::new(); + let successor = SessionId::random(); b.mgr - .session_start(&authn_b, successor.clone(), true) + .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) + .is_some_and(|s| s.session_id() == successor) }) .await; diff --git a/server/tests/output/session-allow-attach-request.bin b/server/tests/output/session-allow-attach-request.bin index ffb4e3d22da4930a3a0c866742dada0c92b8df55..4291cca01f23cdeac6ec0c3d7eef6b4344f7975f 100644 GIT binary patch literal 63 pcmZQzVB}z6V5rK^*R4t|%4Y_$@c~9Iplo7NPO5HVQhsR(BLK&u2RZ-% literal 50 zcmZQzVB}z6V5rK^*R4t|%4g;WauSmg^HTEjbQ6;@b23XRxxhj>sX%^yX$d0$CA$qa diff --git a/server/tests/output/session-deny-attach-request.bin b/server/tests/output/session-deny-attach-request.bin index 9f90f5cc37a736f62ed2fe4bd4d3d66be73521da..c76ca2ddc3172cb444c6b0333db60015b23071c1 100644 GIT binary patch literal 62 ocmZQzVB}z6V5rK^*R4t|%4Y$x@c~9Iplo7NPO5HVQhsR(0Kk_AIsgCw literal 49 ycmZQzVB}z6V5rK^*R4t|%4gvRauSmg^HTEjbQ6;@b23XRxxhj>sX%^yX$b%rrwuj$ diff --git a/server/tests/output/session-start-request.bin b/server/tests/output/session-start-request.bin index af0a9096cb1bdb0f8098d29dc6cf7290c1635a76..b981582489ebbc1b220cd7bff00aaddf404769b7 100644 GIT binary patch literal 48 acmZQzVB}z6V5rK^*R4t|$_Fy>0!9FQY6DOJ literal 35 ocmZQzVB}z6V5rK^*R4t|%4gsQauSmg^HTEjbQ6;@b23XR0f5&DOaK4? diff --git a/server/tests/output/session-stop-request.bin b/server/tests/output/session-stop-request.bin index 635a8a02ab6abdbdb06f9f145b0cd184929b8218..3f0868b06e3a2404fdd85e8e9068e4a7fa65249b 100644 GIT binary patch literal 48 bcmZQzVB}z6V5rK^*R4t|%4Y&2"; @@ -456,11 +448,9 @@ async fn cubby_targets() { .await .unwrap(); 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 target: Target = "14".parse().unwrap(); // With the map empty, a cubby-targeted job records no status here. @@ -559,11 +549,9 @@ async fn root_certs_from_files() { // A job signed by the configured root runs. 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 job = root.sign_job_request(&job_id, "true", false).await; mgr.job_start( @@ -634,11 +622,9 @@ async fn job_output_dir_moves() { .unwrap(); 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(); // Record a job's output under the first base. let first = session.next_job_id(); @@ -728,8 +714,8 @@ async fn universe_swap() { let authn = fake_identity(&mut root).await; async fn run_job(mgr: &JobManager, root: &mut EphemeralKey, authn: &Identity) -> JobId { - let session_id = SessionId::new(); - let session = Session::new(session_id.clone()); + 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; @@ -774,11 +760,9 @@ 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::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 command = "sleep 30"; let job_id = session.next_job_id(); @@ -887,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; @@ -915,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) @@ -1137,8 +1119,8 @@ 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::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 mut start = async |command: &str, target: &str| { @@ -1283,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( @@ -1314,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) )); @@ -1343,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; @@ -1375,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(), ); @@ -1650,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; @@ -1731,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"; @@ -1823,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. From 006ccf97d4c0dc83d0d1e58162681f3d343e4681 Mon Sep 17 00:00:00 2001 From: Emily Albini Date: Tue, 11 Aug 2026 15:13:57 +0200 Subject: [PATCH 30/43] switch job IDs to the newtype --- client/src/commands.rs | 6 +- common/src/borsh.rs | 27 ------ common/src/jobs.rs | 112 +++++++--------------- server/src/executor.rs | 10 +- server/src/history.rs | 2 +- server/src/messages.rs | 1 + server/src/output.rs | 2 +- server/src/state.rs | 25 +++-- server/tests/output/job-start-request.bin | Bin 170 -> 135 bytes tests/src/integration_tests.rs | 22 ++--- tests/src/manager_tests.rs | 6 +- 11 files changed, 70 insertions(+), 143 deletions(-) diff --git a/client/src/commands.rs b/client/src/commands.rs index 4dc1ae2..aa26def 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -1132,7 +1132,7 @@ async fn job_start( 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() @@ -1238,7 +1238,7 @@ async fn job_output( // Fetch job status for output length and hash. let status = job_status_try_from_json_map( with_login_via(ctx, client, Some(target), async || { - client.job_status().job_id(&job_id).send().await + client.job_status().job_id(job_id).send().await }) .await? .into_inner(), @@ -1344,7 +1344,7 @@ async fn job_output( async || { client .job_output() - .job_id(&job_id) + .job_id(job_id) .target(target.to_string()) .stream(stream) .send() diff --git a/common/src/borsh.rs b/common/src/borsh.rs index 97e9133..9925ad0 100644 --- a/common/src/borsh.rs +++ b/common/src/borsh.rs @@ -12,7 +12,6 @@ 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. @@ -87,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::random().first_job_id().to_string()).unwrap(); - assert!(borsh::from_slice::(&good).is_ok()); - } -} diff --git a/common/src/jobs.rs b/common/src/jobs.rs index 3fcd830..78ff42c 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_de_target, borsh_ser_datetime, - borsh_ser_hash, borsh_ser_target, + borsh_de_datetime, borsh_de_hash, borsh_de_target, borsh_ser_datetime, borsh_ser_hash, + borsh_ser_target, }; -use crate::codephrases::{InvalidCodephrase, WORD_SEPARATOR, decode_phrase, id_phrase}; use crate::interactive::InteractiveJobError; use crate::keys::{KeyId, Signed, ToBeSigned, Verified}; use crate::targets::Target; -/// 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) - } +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,7 +56,13 @@ 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()) + } +} + +impl From<&JobId> for JobId { + fn from(value: &JobId) -> Self { + *value } } @@ -129,19 +87,15 @@ codephrase_newtype! { impl SessionId { pub fn first_job_id(&self) -> JobId { - U256::from_be_slice(hash(&self.0.to_be_bytes()).as_bytes()).into() + JobId::from_hash(hash(&self.0.to_be_bytes())) } pub fn next_job_id(&self, last_job: &LastJob) -> JobId { - U256::from_be_slice( - 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.as_bytes()].concat()), - } - .as_bytes(), - ) - .into() + 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()), + }) } } @@ -290,7 +244,7 @@ impl ToBeSigned for JobStartRequest { 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()); diff --git a/server/src/executor.rs b/server/src/executor.rs index 603d119..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, @@ -372,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. @@ -381,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, @@ -391,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/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/messages.rs b/server/src/messages.rs index 8efac53..ff770e2 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -44,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( diff --git a/server/src/output.rs b/server/src/output.rs index 58283f2..6fbf1e5 100644 --- a/server/src/output.rs +++ b/server/src/output.rs @@ -195,7 +195,7 @@ impl JobOutputDir { 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, )), diff --git a/server/src/state.rs b/server/src/state.rs index 79ccf66..27da3ed 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -182,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)> { @@ -201,7 +201,7 @@ impl<'a> SessionGuard<'a> { 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. @@ -215,13 +215,13 @@ impl<'a> SessionGuard<'a> { // 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.clone(), (job, params)); + self.queued_jobs.insert(job_id, (job, params)); if targeted { history.set_job_status( &job_id, own_baseboard, JobStatus::Queued { - job_id: job_id.clone(), + job_id, time_queued: Utc::now(), actor: actor.clone(), }, @@ -248,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(), }), @@ -285,7 +285,7 @@ impl<'a> SessionGuard<'a> { .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); } @@ -745,13 +745,12 @@ impl State { 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()), @@ -764,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, @@ -772,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(), @@ -793,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(), }, diff --git a/server/tests/output/job-start-request.bin b/server/tests/output/job-start-request.bin index a6485051bcbd630779692b756e51a96b18f0b77b..ccaecd4d2209bdc74c1096ed23cecd6b9958914e 100644 GIT binary patch delta 56 icmZ3**v@Fcz`(@8z`#(IpRZe$T9glD;suNogTn#U76tDB literal 170 zcmZQzVB%n4V5rK^*R4t|%4e_#auSmg^HTEjbV;W(b23XRxqwEbCTHX;WTfWghGxk2up_Kt1~P%x14#x30X{Irzz}fl4kL*9ggb-@Dg*#p_bWdD diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index 2ab7a79..f11df1c 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -112,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) @@ -125,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() @@ -209,7 +209,7 @@ async fn client_proxy_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) @@ -222,7 +222,7 @@ async fn client_proxy_server() { .expect("can't start job"); let socket = client .job_attach() - .job_id(&job_id) + .job_id(job_id) .target(test_baseboard_id().to_string()) .send() .await @@ -244,7 +244,7 @@ async fn client_proxy_server() { // A target the proxy cannot route is refused at the proxy. let Err(unrouted) = client .job_output() - .job_id(&job_id) + .job_id(job_id) .stream(JobOutputStream::Stdout) .target("913-0000019:BRM99999999") .send() @@ -552,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) @@ -567,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 @@ -596,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 @@ -637,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 @@ -757,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 @@ -767,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 2f0b052..743a084 100644 --- a/tests/src/manager_tests.rs +++ b/tests/src/manager_tests.rs @@ -251,8 +251,8 @@ async fn job_stop() { )); // Skip the cancelled job. - session.skip_job(job_id.clone()); - mgr.session_skip_job(&authn, session_id, 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"); @@ -1125,7 +1125,7 @@ async fn job_targets() { let mut start = async |command: &str, target: &str| { let job_id = session.next_job_id(); - let request = JobStartRequest::new(job_id.clone(), command, false, target.parse().unwrap()); + let request = JobStartRequest::new(job_id, command, false, target.parse().unwrap()); let job = root .sign(request) .await From 32040567217a2b1e32bcd801fb9daba51db7cadd Mon Sep 17 00:00:00 2001 From: Emily Albini Date: Tue, 11 Aug 2026 17:18:06 +0200 Subject: [PATCH 31/43] switch key IDs to the newtype --- client/src/identity.rs | 5 +- common/src/authn.rs | 20 +++--- common/src/keys.rs | 68 ++++++------------ server/src/messages.rs | 21 +++--- .../tests/output/identity-login-request.bin | Bin 241 -> 281 bytes server/tests/output/job-start-request.bin | Bin 135 -> 175 bytes .../output/session-allow-attach-request.bin | Bin 63 -> 101 bytes .../output/session-deny-attach-request.bin | Bin 62 -> 100 bytes server/tests/output/session-start-request.bin | Bin 48 -> 68 bytes server/tests/output/session-stop-request.bin | Bin 48 -> 68 bytes tests/src/manager_tests.rs | 4 +- 11 files changed, 49 insertions(+), 69 deletions(-) diff --git a/client/src/identity.rs b/client/src/identity.rs index 3763126..bb89387 100644 --- a/client/src/identity.rs +++ b/client/src/identity.rs @@ -171,7 +171,7 @@ impl IdentityError { #[cfg(test)] mod test { - use sush_common::codephrases::{PHRASE_WORDS_ID, WORD_SEPARATOR, generate_id}; + use sush_common::codephrases::generate_id; use tempfile::TempDir; use tokio::process::Command; @@ -214,9 +214,6 @@ 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(); diff --git a/common/src/authn.rs b/common/src/authn.rs index 6a1be55..609bc79 100644 --- a/common/src/authn.rs +++ b/common/src/authn.rs @@ -268,7 +268,7 @@ impl BoundRequest { seq, } = self; hash(Self::TYPE_NAME); - hash(key_id.as_bytes()); + hash(&key_id.to_be_bytes()); hash(&nonce.to_be_bytes()); hash(method.as_bytes()); hash(target.as_bytes()); @@ -311,7 +311,7 @@ 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")?, + key_id: params.parse("key-id")?, nonce: params.parse("nonce")?, seq: params.parse("seq")?, signature: EncodedSignature { @@ -523,7 +523,7 @@ impl FromStr for Credentials { let creds = Self { nonce: params.parse("nonce")?, cnonce: params.parse("cnonce")?, - key_id: params.get("key-id")?, + key_id: params.parse("key-id")?, epk: params.parse("epk")?, signature: EncodedSignature { r: params.get("r")?, @@ -730,7 +730,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::random(), &request); + let creds = key.bind( + KeyId::from_str("abandon").unwrap(), + Nonce::random(), + &request, + ); assert_eq!( creds.to_string().parse::().unwrap(), creds @@ -759,7 +763,7 @@ 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()); @@ -835,21 +839,21 @@ mod test { let epk = RequestKey::new().verifier(); assert!(matches!( Credentials::from_str( - "Sush nonce=abandon,cnonce=ability,key-id=baz,epk=plugh,r=r,s=s,flags=0,counter=0" + "Sush nonce=abandon,cnonce=ability,key-id=able,epk=plugh,r=r,s=s,flags=0,counter=0" ) .unwrap_err(), AuthnError::InvalidParam )); assert!(matches!( Credentials::from_str(&format!( - "Sush nonce=abandon,cnonce=ability,key-id=baz,epk={epk},r=r,s=s,flags=0,counter=foo" + "Sush nonce=abandon,cnonce=ability,key-id=able,epk={epk},r=r,s=s,flags=0,counter=foo" )) .unwrap_err(), AuthnError::InvalidParam )); assert!(matches!( Credentials::from_str(&format!( - "Sush nonce=abandon,cnonce=ability,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=r,s=s,flags=0,counter=0,foo=bar" )) .unwrap_err(), AuthnError::TooManyParams diff --git a/common/src/keys.rs b/common/src/keys.rs index 6cd2b65..5153b8d 100644 --- a/common/src/keys.rs +++ b/common/src/keys.rs @@ -29,7 +29,6 @@ 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, @@ -46,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, WORD_SEPARATOR, codephrase, decode_phrase}; + +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 { @@ -97,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)) } } @@ -107,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)) } } diff --git a/server/src/messages.rs b/server/src/messages.rs index ff770e2..3aa7cae 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -159,6 +159,7 @@ pub mod v0 { } #[derive(BorshDeserialize, BorshSerialize, Clone, Debug, Eq, PartialEq)] + #[allow(clippy::large_enum_variant)] pub enum JobRequest { Start(SignedJob, JobStartParams), Stop(JobId), @@ -274,7 +275,7 @@ mod wire_format { #[test] fn session_start_request() { let msg: VersionedMessage = Message::Request(Request::session( - KeyId::from("zoo-zero".to_string()), + KeyId::from_str("zoo-zero").unwrap(), SessionRequest::Start(sid("abandon-ability")), )) .into(); @@ -284,7 +285,7 @@ mod wire_format { #[test] fn session_stop_request() { let msg: VersionedMessage = Message::Request(Request::session( - KeyId::from("zoo-zero".to_string()), + KeyId::from_str("zoo-zero").unwrap(), SessionRequest::Stop(sid("abandon-ability")), )) .into(); @@ -294,10 +295,10 @@ mod wire_format { #[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( sid("abandon-ability"), - KeyId::from("able-about".to_string()), + KeyId::from_str("able-about").unwrap(), Access::ReadWrite, ), )) @@ -308,10 +309,10 @@ mod wire_format { #[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( sid("abandon-ability"), - KeyId::from("able-about".to_string()), + KeyId::from_str("able-about").unwrap(), ), )) .into(); @@ -333,7 +334,7 @@ mod wire_format { ); let signed = Signed::new( request, - KeyId::from("zoo-zero".to_string()), + KeyId::from_str("zoo-zero").unwrap(), EncodedSignature { r: "abandon".to_string(), s: "zoo".to_string(), @@ -342,7 +343,7 @@ mod wire_format { }, ); let msg: VersionedMessage = Message::Request(Request::job( - KeyId::from("zoo-zero".to_string()), + KeyId::from_str("zoo-zero").unwrap(), JobRequest::Start(signed, JobStartParams::default()), )) .into(); @@ -375,7 +376,7 @@ mod wire_format { let signed = Signed::new( response, - KeyId::from("zoo-zero".to_string()), + KeyId::from_str("zoo-zero").unwrap(), EncodedSignature { r: "abandon".to_string(), s: "zoo".to_string(), @@ -384,7 +385,7 @@ mod wire_format { }, ); let msg: VersionedMessage = Message::Request(Request::identity( - KeyId::from("zoo-zero".to_string()), + KeyId::from_str("zoo-zero").unwrap(), IdentityRequest::Login(public_key, signed), )) .into(); diff --git a/server/tests/output/identity-login-request.bin b/server/tests/output/identity-login-request.bin index ae710271e52dfa00111a04a12650eeff9b367d8d..af1475a92b41a0ec7a21a531fbd170f0eb33e6b1 100644 GIT binary patch delta 48 zcmey!IFpH)fq{8qp)9lg|GyLY_XAl{Ko&a#14Cj`VqQvq9#Ed4DnB1cGB5xDf%Fae delta 59 ycmbQq^pTODfq|KWfq|haKVP>hwJ3k0@_uDxK08n@F)1-GB|ncDq6|nfFaQ9%jSd9> diff --git a/server/tests/output/job-start-request.bin b/server/tests/output/job-start-request.bin index ccaecd4d2209bdc74c1096ed23cecd6b9958914e..76326d2793b4d58cdd3fd68f6de2732bf4e95068 100644 GIT binary patch delta 31 gcmZo?T+hhNz`!(7P?p*L|KEv%auao>VVn?S0EiR`=>Px# delta 45 pcmZ3_*v`n$z`(@8z`#(IpRZe$T9iLAQDR~N$3$mI9u(0UV*vNP3@`uy diff --git a/server/tests/output/session-allow-attach-request.bin b/server/tests/output/session-allow-attach-request.bin index 4291cca01f23cdeac6ec0c3d7eef6b4344f7975f..9ce5e5adc332300e2a7300f22af052dd94b2b5aa 100644 GIT binary patch literal 101 bcmZQzU}V4t?En8|#wQPy!*2kC05c;1nAHMR literal 63 pcmZQzVB}z6V5rK^*R4t|%4Y_$@c~9Iplo7NPO5HVQhsR(BLK&u2RZ-% diff --git a/server/tests/output/session-deny-attach-request.bin b/server/tests/output/session-deny-attach-request.bin index c76ca2ddc3172cb444c6b0333db60015b23071c1..0eacba1b005a68342015be89179d1215390543eb 100644 GIT binary patch literal 100 acmZQzU}V4t?En8|!6y%t!*2kC05brV!2(qP literal 62 ocmZQzVB}z6V5rK^*R4t|%4Y$x@c~9Iplo7NPO5HVQhsR(0Kk_AIsgCw diff --git a/server/tests/output/session-start-request.bin b/server/tests/output/session-start-request.bin index b981582489ebbc1b220cd7bff00aaddf404769b7..cd0df6996ed5a9fe9c340a7b370674cdb542916b 100644 GIT binary patch literal 68 VcmZQzU}V4t?EnA8Ck>Kg1OQFi0zLo$ literal 48 acmZQzVB}z6V5rK^*R4t|$_Fy>0!9FQY6DOJ diff --git a/server/tests/output/session-stop-request.bin b/server/tests/output/session-stop-request.bin index 3f0868b06e3a2404fdd85e8e9068e4a7fa65249b..469491adab5244c316d18154fa603b52e5356728 100644 GIT binary patch literal 68 VcmZQzU}V4t?EnA8uYiGp5dcm50zUu% literal 48 bcmZQzVB}z6V5rK^*R4t|%4Y Date: Tue, 11 Aug 2026 17:44:40 +0200 Subject: [PATCH 32/43] switch encoded signatures to the newtype --- common/src/authn.rs | 29 +++--- common/src/keys.rs | 83 ++++++++++++------ server/src/messages.rs | 8 +- .../tests/output/identity-login-request.bin | Bin 281 -> 327 bytes server/tests/output/job-start-request.bin | Bin 175 -> 221 bytes 5 files changed, 74 insertions(+), 46 deletions(-) diff --git a/common/src/authn.rs b/common/src/authn.rs index 609bc79..89a84f1 100644 --- a/common/src/authn.rs +++ b/common/src/authn.rs @@ -43,7 +43,9 @@ use thiserror::Error; use crate::codephrase_newtype; use crate::codephrases::{InvalidCodephrase, WORD_SEPARATOR, codephrase, decode_phrase}; -use crate::keys::{EncodedSignature, KeyError, KeyId, Signed, SshPublicKey, ToBeSigned, Verified}; +use crate::keys::{ + EccR, EccS, EncodedSignature, KeyError, KeyId, Signed, SshPublicKey, ToBeSigned, Verified, +}; /// The name of our custom HTTP authentication scheme. pub const AUTHN_SCHEME: &str = "Sush"; @@ -139,14 +141,13 @@ impl RequestKey { /// 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, }, @@ -172,11 +173,8 @@ 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)?); + let signature = Ed25519Signature::from_components(r.to_be_bytes(), s.to_be_bytes()); self.0 .verify_strict( &request.to_be_signed(&credentials.key_id, &credentials.nonce), @@ -315,8 +313,9 @@ impl FromStr for BoundCredentials { 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, }, @@ -526,8 +525,8 @@ impl FromStr for Credentials { 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")?, }, @@ -839,21 +838,21 @@ mod test { let epk = RequestKey::new().verifier(); assert!(matches!( Credentials::from_str( - "Sush nonce=abandon,cnonce=ability,key-id=able,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=abandon,cnonce=ability,key-id=able,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=abandon,cnonce=ability,key-id=able,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/keys.rs b/common/src/keys.rs index 5153b8d..b35b380 100644 --- a/common/src/keys.rs +++ b/common/src/keys.rs @@ -18,7 +18,7 @@ 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, @@ -45,7 +45,7 @@ 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}; +use crate::codephrases::InvalidCodephrase; codephrase_newtype! { /// SHA-256 of a certificate subject or an identity public key, @@ -165,6 +165,42 @@ impl JsonSchema for SshPublicKey { } } +codephrase_newtype! { + /// The component `r` of a signature _(r, s)_ over a 256 bit elliptic curve. + #[derive( + BorshDeserialize, + BorshSerialize, + Clone, + Deserialize, + Eq, + Hash, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + )] + pub struct EccR = Full; +} + +codephrase_newtype! { + /// The component `s` of a signature _(r, s)_ over a 256 bit elliptic curve. + #[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 @@ -188,8 +224,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, @@ -219,8 +255,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, @@ -231,10 +267,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), } } @@ -248,16 +281,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)?; @@ -306,17 +336,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, }), @@ -327,8 +356,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, }) @@ -337,8 +366,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, }) @@ -399,13 +428,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. diff --git a/server/src/messages.rs b/server/src/messages.rs index 3aa7cae..9ed896a 100644 --- a/server/src/messages.rs +++ b/server/src/messages.rs @@ -336,8 +336,8 @@ mod wire_format { request, KeyId::from_str("zoo-zero").unwrap(), EncodedSignature { - r: "abandon".to_string(), - s: "zoo".to_string(), + r: "abandon".parse().unwrap(), + s: "zoo".parse().unwrap(), flags: 0, counter: 0, }, @@ -378,8 +378,8 @@ mod wire_format { response, KeyId::from_str("zoo-zero").unwrap(), EncodedSignature { - r: "abandon".to_string(), - s: "zoo".to_string(), + r: "abandon".parse().unwrap(), + s: "zoo".parse().unwrap(), flags: 0, counter: 0, }, diff --git a/server/tests/output/identity-login-request.bin b/server/tests/output/identity-login-request.bin index af1475a92b41a0ec7a21a531fbd170f0eb33e6b1..90e9242db401d94f5834ec05e2de012a4385cfc7 100644 GIT binary patch delta 21 acmbQqbew4e6XV2*1``+Xv9teY0096@c?Grr delta 31 jcmX@kG?Qrq6Qejg0|P^1Qes|8ejYQBS(Tp;BpDb0b}9yB diff --git a/server/tests/output/job-start-request.bin b/server/tests/output/job-start-request.bin index 76326d2793b4d58cdd3fd68f6de2732bf4e95068..3fa78f1f74a6d66051890cb05ae893aae70d4d47 100644 GIT binary patch delta 27 ecmZ3_c$aZP%|vB|i3OYx#LB?H!2W+?pCSN-Xb9K< delta 28 icmcc1xSnxBjSxEn14Cj`VqQvq9y5?xm7hOxk0JnecnDAc From 2a176166747a254ce50017a4c77dfd1af5146175 Mon Sep 17 00:00:00 2001 From: Emily Albini Date: Tue, 11 Aug 2026 18:02:34 +0200 Subject: [PATCH 33/43] switch request verifier to the newtype --- common/src/authn.rs | 80 +++++++++++++-------------------------------- 1 file changed, 22 insertions(+), 58 deletions(-) diff --git a/common/src/authn.rs b/common/src/authn.rs index 89a84f1..3ccd184 100644 --- a/common/src/authn.rs +++ b/common/src/authn.rs @@ -30,10 +30,9 @@ use std::str::FromStr; use std::time::Duration; use blake3::Hasher; -use borsh::io::{Error, ErrorKind, Read, Write}; 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; @@ -42,7 +41,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::codephrase_newtype; -use crate::codephrases::{InvalidCodephrase, WORD_SEPARATOR, codephrase, decode_phrase}; +use crate::codephrases::InvalidCodephrase; use crate::keys::{ EccR, EccS, EncodedSignature, KeyError, KeyId, Signed, SshPublicKey, ToBeSigned, Verified, }; @@ -135,7 +134,7 @@ 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. @@ -155,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, @@ -175,53 +173,20 @@ impl RequestVerifier { } let EncodedSignature { r, s, .. } = &credentials.signature; let signature = Ed25519Signature::from_components(r.to_be_bytes(), s.to_be_bytes()); - self.0 - .verify_strict( - &request.to_be_signed(&credentials.key_id, &credentials.nonce), - &signature, - ) - .map_err(|_| AuthnError::InvalidSignature) - } -} -impl BorshSerialize for RequestVerifier { - fn serialize(&self, writer: &mut W) -> borsh::io::Result<()> { - writer.write_all(self.as_bytes()) - } -} - -impl BorshDeserialize for RequestVerifier { - fn deserialize_reader(reader: &mut R) -> borsh::io::Result { - let mut bytes = [0; 32]; - reader.read_exact(&mut bytes)?; - VerifyingKey::from_bytes(&bytes) - .map(Self) - .map_err(|err| Error::new(ErrorKind::InvalidData, err)) - } -} - -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; - - 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) } } @@ -432,7 +397,7 @@ impl ToBeSigned for ChallengeResponse { hash(Self::TYPE_NAME); hash(&nonce.to_be_bytes()); hash(&cnonce.to_be_bytes()); - hash(epk.as_bytes()); + hash(&epk.to_be_bytes()); hasher.finalize().as_bytes().to_vec() } } @@ -768,19 +733,18 @@ mod test { assert!(verifier.verify(&request, &relabeled).is_err()); let relabeled = BoundCredentials { nonce: Nonce::random(), - ..creds + ..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 )); } From 991ea72c05c1f047a21e9d8609b0e13780052693 Mon Sep 17 00:00:00 2001 From: Emily Albini Date: Tue, 11 Aug 2026 18:16:39 +0200 Subject: [PATCH 34/43] remove public functions to create codephrases --- client/src/identity.rs | 8 +- common/src/codephrases.rs | 206 +++++++++++++++++-------------------- server/tests/common/mod.rs | 4 +- tests/src/test_utils.rs | 4 +- 4 files changed, 100 insertions(+), 122 deletions(-) diff --git a/client/src/identity.rs b/client/src/identity.rs index bb89387..25d290f 100644 --- a/client/src/identity.rs +++ b/client/src/identity.rs @@ -171,7 +171,7 @@ impl IdentityError { #[cfg(test)] mod test { - use sush_common::codephrases::generate_id; + use sush_common::codephrases::Codephrase; use tempfile::TempDir; use tokio::process::Command; @@ -214,9 +214,9 @@ mod test { let mut agent = SshAgentConnection::connect(&sock).await.unwrap(); for key in agent.list_identities().await.unwrap() { - 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/common/src/codephrases.rs b/common/src/codephrases.rs index 7b39dd3..8f25cf3 100644 --- a/common/src/codephrases.rs +++ b/common/src/codephrases.rs @@ -29,7 +29,12 @@ use std::io::prelude::{Read, Write}; /// 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, @@ -38,14 +43,7 @@ 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; - -/// 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 = "-"; - -const TRUNCATED_MASK: U256 = U256::from_u128(2u128.pow(88) - 1); +const TRUNCATED_BITS: u32 = 88; /// Random code phrase like `abstract misery favorite ordinary moon talk`. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -57,10 +55,16 @@ impl Codephrase { Self(U256::random(&mut OsRng)) } + /// 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 { - Self(self.0.bitand(&TRUNCATED_MASK)) + 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)) } @@ -73,7 +77,18 @@ impl Codephrase { impl std::fmt::Display for Codephrase { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&codephrase(self.0).join(WORD_SEPARATOR)) + // 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)) } } @@ -87,7 +102,34 @@ impl FromStr for Codephrase { type Err = InvalidCodephrase; fn from_str(value: &str) -> Result { - Ok(Self(decode_phrase(value)?)) + // 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)) } } @@ -100,7 +142,7 @@ impl Serialize for Codephrase { impl<'de> Deserialize<'de> for Codephrase { fn deserialize>(deserializer: D) -> Result { let string = ::deserialize(deserializer)?; - Ok(Self(decode_phrase(&string).map_err(D::Error::custom)?)) + Self::from_str(&string).map_err(D::Error::custom) } } @@ -230,80 +272,16 @@ fn index(word: &str) -> Result { WORDLIST.binary_search(&word).map_err(|_| InvalidCodephrase) } -/// 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 - } - 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 - } - phrase -} - -/// Generate a code phrase for use as an identifier. -pub fn generate_id() -> String { - id_phrase(U256::random(&mut OsRng)).join(WORD_SEPARATOR) -} - -/// 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); - } - 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) -} - #[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] @@ -336,44 +314,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" ); } @@ -414,22 +397,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/server/tests/common/mod.rs b/server/tests/common/mod.rs index baacc3e..1a62c12 100644 --- a/server/tests/common/mod.rs +++ b/server/tests/common/mod.rs @@ -26,7 +26,7 @@ use sprockets_tls_test_utils::{ private_key_path, root_prefix, sprockets_auth_prefix, }; use sush_common::authn::{Challenge, ChallengeResponse, Identity, Nonce, RequestKey}; -use sush_common::codephrases::generate_id; +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; @@ -126,7 +126,7 @@ pub async fn eventually(what: &str, secs: u64, mut condition: impl AsyncFnMut() pub fn ephemeral_root() -> EphemeralKey { let mut buf = [0; 8]; OsRng.fill_bytes(&mut buf); - let id = generate_id(); + let id = Codephrase::random().truncate(); EphemeralKey::new_root( KeyType::P256, format!("CN=Ephemeral Test Key {id},O=Oxide Computer Company,C=US") diff --git a/tests/src/test_utils.rs b/tests/src/test_utils.rs index a159447..7f7a87e 100644 --- a/tests/src/test_utils.rs +++ b/tests/src/test_utils.rs @@ -25,7 +25,7 @@ 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}; @@ -110,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() From b0db7e33d7e3c9a8fed511792a4d33322e2962cc Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Wed, 12 Aug 2026 01:45:57 +0000 Subject: [PATCH 35/43] Group codephrase imports Also self-qualify the newtype macro's formatter types, so call sites need no imports of their own. --- common/src/codephrases.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/common/src/codephrases.rs b/common/src/codephrases.rs index 8f25cf3..47aa45b 100644 --- a/common/src/codephrases.rs +++ b/common/src/codephrases.rs @@ -9,8 +9,12 @@ //! that must be readily transmissible over low bandwidth channels (e.g., //! email, voice, printed or handwritten notes, etc.). +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, @@ -22,8 +26,6 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; use crate::wordlist::{WORDLIST, WORDLIST_LEN}; -use borsh::{BorshDeserialize, BorshSerialize}; -use std::io::prelude::{Read, Write}; /// Entropy is treated as an integer whose base is to be changed /// to 2048, which gives us indexes into the BIP-39 word list. @@ -75,8 +77,8 @@ impl Codephrase { } } -impl std::fmt::Display for Codephrase { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +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; @@ -92,8 +94,8 @@ impl std::fmt::Display for Codephrase { } } -impl std::fmt::Debug for Codephrase { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Debug for Codephrase { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Codephrase({self})") } } @@ -147,13 +149,13 @@ impl<'de> Deserialize<'de> for Codephrase { } impl BorshSerialize for Codephrase { - fn serialize(&self, writer: &mut W) -> std::io::Result<()> { + 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) -> std::io::Result { + 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()))) } @@ -173,7 +175,7 @@ impl JsonSchema for Codephrase { String::is_referenceable() } - fn schema_id() -> std::borrow::Cow<'static, str> { + fn schema_id() -> Cow<'static, str> { String::schema_id() } } @@ -228,13 +230,13 @@ macro_rules! codephrase_newtype { } impl std::fmt::Debug for $name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + 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 fmt::Formatter<'_>) -> fmt::Result { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { <$crate::codephrases::Codephrase as std::fmt::Display>::fmt(&self.0, f) } } From 6071f9eef0b1dd835b0d95a3735e7483ec2a7280 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Wed, 12 Aug 2026 01:49:36 +0000 Subject: [PATCH 36/43] Say what EccR and EccS hold for Ed25519 --- common/src/keys.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/common/src/keys.rs b/common/src/keys.rs index b35b380..7dcdc84 100644 --- a/common/src/keys.rs +++ b/common/src/keys.rs @@ -166,7 +166,10 @@ impl JsonSchema for SshPublicKey { } codephrase_newtype! { - /// The component `r` of a signature _(r, s)_ over a 256 bit elliptic curve. + /// 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, @@ -184,7 +187,10 @@ codephrase_newtype! { } codephrase_newtype! { - /// The component `s` of a signature _(r, s)_ over a 256 bit elliptic curve. + /// 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, From bf0d5484bb9296461d5a1981ba367cfd8c7495a9 Mon Sep 17 00:00:00 2001 From: Emily Albini Date: Wed, 12 Aug 2026 12:48:11 +0200 Subject: [PATCH 37/43] update openapi spec --- sush.json | 484 +++++++++++++++++++++++++++--------------------------- 1 file changed, 246 insertions(+), 238 deletions(-) diff --git a/sush.json b/sush.json index c6b095e..5e8a215 100644 --- a/sush.json +++ b/sush.json @@ -430,18 +430,18 @@ }, { "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" } }, { @@ -487,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", @@ -520,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", @@ -550,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": { @@ -722,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" } }, { @@ -774,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" } } ], @@ -817,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" } } ], @@ -963,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", @@ -993,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" @@ -1016,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": [ { @@ -1028,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": [ @@ -1042,6 +1146,13 @@ "$ref": "#/components/schemas/KeyId" } ] + }, + "job_id": { + "$ref": "#/components/schemas/JobId" + }, + "time_cancelled": { + "type": "string", + "format": "date-time" } }, "required": [ @@ -1062,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": [ @@ -1076,6 +1180,13 @@ "$ref": "#/components/schemas/KeyId" } ] + }, + "job_id": { + "$ref": "#/components/schemas/JobId" + }, + "time_queued": { + "type": "string", + "format": "date-time" } }, "required": [ @@ -1096,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": [ @@ -1153,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" @@ -1160,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": [ @@ -1184,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": { @@ -1241,10 +1390,10 @@ "Io": { "type": "object", "properties": { - "what": { + "error": { "type": "string" }, - "error": { + "what": { "type": "string" } }, @@ -1265,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": [ @@ -1299,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": [ { @@ -1334,116 +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" - }, - "target": { - "description": "The sleds this job runs on.", - "type": "string" - } - }, - "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.", @@ -1463,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": { From 9004fd6e8e716c47f94405d5c64dede815c59e7f Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Tue, 11 Aug 2026 20:55:43 +0000 Subject: [PATCH 38/43] Use rustyline's termios backend The default backend rebuilds termios from typed flags, dropping c_cflag bits nix doesn't model. On illumos that zeroes the baud rate, and B0 at raw-mode entry hangs up the terminal. --- Cargo.lock | 10 ++++++++++ Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 14b788f..1c4f3bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3945,6 +3945,7 @@ dependencies = [ "memchr", "nix", "radix_trie", + "termios", "unicode-segmentation", "unicode-width 0.2.2", "utf8parse", @@ -5042,6 +5043,15 @@ dependencies = [ "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" diff --git a/Cargo.toml b/Cargo.toml index 6ba7afe..3cba426 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ rlimit = "0.10" rumors = { git = "https://github.com/oxidecomputer/rumors", rev = "66aea68b6578345275de3cf14151c4707345773f" } rustix = { version = "1", features = ["fs", "process", "pty", "termios"] } rustls = "0.23" -rustyline = "17" +rustyline = { version = "17", features = ["termios"] } schemars = { version = "0.8", features = ["chrono"] } serde = "1" serde_json = "1" From 7f9d32adb7de9817b47a2490221e06dbced2cb04 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Tue, 11 Aug 2026 23:50:05 +0000 Subject: [PATCH 39/43] Shorten job status and fetch output rack-wide One status line per sled by default (--full restores the blocks), and bare `job stdout` walks every sled with recorded status instead of resolving to one. --- client/src/cli.rs | 66 ++++++++++++++++++++++++++++++- client/src/commands.rs | 90 ++++++++++++++++++++++++++++++++++++++---- client/src/context.rs | 12 +++++- client/src/repl.rs | 11 ++++-- 4 files changed, 166 insertions(+), 13 deletions(-) diff --git a/client/src/cli.rs b/client/src/cli.rs index 76aad00..4768a57 100644 --- a/client/src/cli.rs +++ b/client/src/cli.rs @@ -15,6 +15,7 @@ use humantime::format_duration; use indicatif::{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,7 +28,7 @@ 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 { @@ -218,6 +219,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, @@ -397,7 +405,7 @@ impl CommandContext for Cli { } } - fn job_status(&mut self, _job_id: &JobId, status: &JobStatusMap) { + fn job_status(&mut self, job_id: &JobId, status: &JobStatusMap, style: StatusDisplayStyle) { fn format_elapsed_duration(duration: TimeDelta) -> String { if let Ok(duration) = duration.to_std() { format_duration(duration).to_string() @@ -409,6 +417,60 @@ impl CommandContext for Cli { // TODO: parallel status display 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(); + match status { + JobStatus::Cancelled { + time_cancelled, + actor, + .. + } => { + println!(" {id: println!(" {id: { + println!(" {id: { + 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(), + }; + println!( + " {id: println!(" {id: { for (baseboard_id, status) in status { match status { diff --git a/client/src/commands.rs b/client/src/commands.rs index aa26def..cac6108 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -52,7 +52,7 @@ 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")] @@ -195,6 +195,31 @@ 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)); +} + /// [`ClientArgs`] must satisfy Clap's internal consistency asserts /// (unique shorts per subcommand, valid references). #[test] @@ -397,6 +422,10 @@ 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, }, /// Get the standard output of a job. @@ -977,15 +1006,20 @@ async fn job( Ok(()) } - (JobCommand::Status { job_id }, Some(client)) => job_status(ctx, client, &job_id).await, + (JobCommand::Status { job_id, full }, Some(client)) => { + let style = if full { + StatusDisplayStyle::Full + } else { + StatusDisplayStyle::Short + }; + job_status(ctx, client, &job_id, style).await + } (JobCommand::Stdout { target, output }, Some(client)) => { - let target = resolve_target(client, &target).await?; job_output(ctx, client, &target, Stdout, output).await } (JobCommand::Stderr { target, output }, Some(client)) => { - let target = resolve_target(client, &target).await?; job_output(ctx, client, &target, Stderr, output).await } @@ -1010,7 +1044,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(()) @@ -1078,7 +1112,9 @@ 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() { @@ -1127,7 +1163,7 @@ async fn job_start( } // Show the job status and output. - job_status(ctx, client, &job_id).await?; + job_status(ctx, client, &job_id, StatusDisplayStyle::Short).await?; for stream in [Stdout, Stderr] { match with_login_via(ctx, client, Some(&target), async || { client @@ -1175,6 +1211,7 @@ async fn job_status( ctx: &mut impl CommandContext, client: &Client, job_id: &JobId, + style: StatusDisplayStyle, ) -> Result<(), CommandError> { let status = with_login(ctx, client, async || { client.job_status().job_id(job_id).send().await @@ -1184,6 +1221,7 @@ async fn job_status( ctx.job_status( job_id, &job_status_try_from_json_map(status).map_err(CommandError::BaseboardIdParseError)?, + style, ); Ok(()) } @@ -1222,6 +1260,42 @@ type FutureChunk<'a> = dyn Future> + Send + /// Download job output. async fn job_output( + ctx: &mut impl CommandContext, + client: &Client, + target: &Target, + stream: JobOutputStream, + args: JobOutput, +) -> Result<(), CommandError> { + 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_try_from_json_map( + with_login(ctx, client, async || { + client.job_status().job_id(&args.job_id).send().await + }) + .await? + .into_inner(), + ) + .map_err(CommandError::BaseboardIdParseError)?; + 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, @@ -1550,6 +1624,8 @@ 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), diff --git a/client/src/context.rs b/client/src/context.rs index 281a32c..f3dea9f 100644 --- a/client/src/context.rs +++ b/client/src/context.rs @@ -11,6 +11,7 @@ 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 +63,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 +125,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, @@ -134,7 +144,7 @@ pub trait CommandContext: Clone + Send + Sync { 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/repl.rs b/client/src/repl.rs index 8afe8ef..81666d5 100644 --- a/client/src/repl.rs +++ b/client/src/repl.rs @@ -15,6 +15,7 @@ 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 +30,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"; @@ -260,6 +261,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); @@ -325,9 +330,9 @@ impl CommandContext for Repl { self.cli.job_signed(job, show); } - fn job_status(&mut self, job_id: &JobId, status: &JobStatusMap) { + 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 { From c9362c48644ec3ea757d3a4d0ab1ff59016ea425 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Wed, 12 Aug 2026 00:05:03 +0000 Subject: [PATCH 40/43] Accept bare serials as output and attach targets The client resolves a serial (case-insensitively) against the sleds that have a status for the job. Signed targets are unchanged. --- client/src/commands.rs | 116 +++++++++++++++++++++++++++++++++-------- 1 file changed, 94 insertions(+), 22 deletions(-) diff --git a/client/src/commands.rs b/client/src/commands.rs index cac6108..1440723 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,8 +46,8 @@ 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}; @@ -211,7 +212,7 @@ async fn output_needs_target() { let err = job_output( &mut Cli::default(), &client, - &"*".parse::().unwrap(), + &"*".parse::().unwrap(), Stdout, output, ) @@ -220,6 +221,20 @@ async fn output_needs_target() { assert!(matches!(err, CommandError::OutputNeedsTarget)); } +/// 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] @@ -433,7 +448,7 @@ pub enum JobCommand { Stdout { /// The sled from which output should be fetched. #[arg(short = 'T', long, default_value = "*")] - target: Target, + target: TargetArg, #[clap(flatten)] output: JobOutput, @@ -444,7 +459,7 @@ pub enum JobCommand { Stderr { /// The sled from which output should be fetched. #[arg(short = 'T', long, default_value = "*")] - target: Target, + target: TargetArg, #[clap(flatten)] output: JobOutput, @@ -458,7 +473,7 @@ pub enum JobCommand { /// The sled to attach to. #[arg(short = 'T', long, default_value = "*")] - target: Target, + target: TargetArg, }, /// Show status of previously started jobs. @@ -1024,7 +1039,10 @@ async fn job( } (JobCommand::Attach { job_id, target }, Some(client)) => { - let target = resolve_target(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(()) } @@ -1213,17 +1231,23 @@ async fn job_status( 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)?, - style, - ); - Ok(()) + job_status_try_from_json_map(status).map_err(CommandError::BaseboardIdParseError) } /// Stream some bytes into a vector. @@ -1262,10 +1286,17 @@ type FutureChunk<'a> = dyn Future> + Send + async fn job_output( ctx: &mut impl CommandContext, client: &Client, - target: &Target, + 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; @@ -1275,14 +1306,7 @@ async fn job_output( } // Fetch output from every sled with a recorded status. - let status = job_status_try_from_json_map( - with_login(ctx, client, async || { - client.job_status().job_id(&args.job_id).send().await - }) - .await? - .into_inner(), - ) - .map_err(CommandError::BaseboardIdParseError)?; + let status = job_status_map(ctx, client, &args.job_id).await?; if status.is_empty() { return Err(CommandError::NotFound); } @@ -1545,9 +1569,55 @@ async fn resolve_target(client: &Client, target: &Target) -> Result::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, + job_id: &JobId, + serial: &str, +) -> Result { + 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")] @@ -1654,6 +1724,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}")] From bdced1d887abaed86f0c02543e16de1d8b1e6787 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Wed, 12 Aug 2026 04:11:17 +0000 Subject: [PATCH 41/43] Watch job status with a line per sled `job start -w` and the new `job status --wait` poll the rack-wide status map and render a live line per sled, settling once every known sled is terminal and gossip has had a grace period to name stragglers. --- client/src/cli.rs | 178 +++++++++++++++++++++++-------------- client/src/commands.rs | 195 +++++++++++++++++++++++++++++++++++------ client/src/context.rs | 7 +- client/src/repl.rs | 25 +++--- common/src/jobs.rs | 8 ++ 5 files changed, 305 insertions(+), 108 deletions(-) diff --git a/client/src/cli.rs b/client/src/cli.rs index 4768a57..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,7 +14,7 @@ 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; @@ -35,6 +37,7 @@ pub struct Cli { globals: Arc>, output: Arc>, progress: Arc>>, + watch: Arc>>, session: Arc>>, credentials: AuthzSigner, } @@ -43,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 @@ -210,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}`"); + } + } } } @@ -309,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 match self.get_output_format() { OutputFormat::Json => println!("{}", json!(job_status_to_json_map(status.clone()))), OutputFormat::Text if matches!(style, StatusDisplayStyle::Short) => { @@ -438,37 +516,7 @@ impl CommandContext for Cli { println!("{icon} Job ID:\t{job_id}"); for (baseboard_id, status) in status { let id = baseboard_id.to_string(); - match status { - JobStatus::Cancelled { - time_cancelled, - actor, - .. - } => { - println!(" {id: println!(" {id: { - println!(" {id: { - 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(), - }; - println!( - " {id: println!(" {id: { diff --git a/client/src/commands.rs b/client/src/commands.rs index 1440723..ad29c6b 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -89,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); @@ -221,6 +220,60 @@ async fn output_needs_target() { 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] @@ -441,6 +494,10 @@ pub enum JobCommand { /// 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. @@ -1021,13 +1078,19 @@ async fn job( Ok(()) } - (JobCommand::Status { job_id, full }, Some(client)) => { + (JobCommand::Status { job_id, full, wait }, Some(client)) => { let style = if full { StatusDisplayStyle::Full } else { StatusDisplayStyle::Short }; - job_status(ctx, client, &job_id, style).await + 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)) => { @@ -1136,36 +1199,60 @@ async fn job_start( 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; @@ -1173,15 +1260,17 @@ 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, StatusDisplayStyle::Short).await?; + }; + ctx.job_watch_finished(&job_id); + ctx.job_status(&job_id, &status, StatusDisplayStyle::Short); for stream in [Stdout, Stderr] { match with_login_via(ctx, client, Some(&target), async || { client @@ -1250,6 +1339,60 @@ async fn job_status_map( 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. async fn byte_stream_to_vec(mut stream: ByteStream) -> Result, CommandError> { let mut output = Vec::new(); diff --git a/client/src/context.rs b/client/src/context.rs index f3dea9f..b8d6bbe 100644 --- a/client/src/context.rs +++ b/client/src/context.rs @@ -8,7 +8,6 @@ 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; @@ -135,9 +134,9 @@ 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); diff --git a/client/src/repl.rs b/client/src/repl.rs index 81666d5..2e101ad 100644 --- a/client/src/repl.rs +++ b/client/src/repl.rs @@ -9,7 +9,6 @@ use std::env; use std::ffi::OsString; use std::path::Path; -use std::time::Duration; use clap::Parser; use rustyline::DefaultEditor; @@ -294,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); } @@ -330,6 +317,18 @@ impl CommandContext for Repl { self.cli.job_signed(job, show); } + 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, style); diff --git a/common/src/jobs.rs b/common/src/jobs.rs index 78ff42c..2cf079c 100644 --- a/common/src/jobs.rs +++ b/common/src/jobs.rs @@ -415,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 { From c22f24e18ac9f8583921e51afc881e1ae985e331 Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Wed, 12 Aug 2026 14:51:45 +0000 Subject: [PATCH 42/43] Freshen the README job example The --wait transcript shows the short per-sled status now, and the prose mentions the live watch and the --wait/--full status flags. --- README.md | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) 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`): From 999387953e55c732ba66f37d1e6121bf5e3c6b8e Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Wed, 12 Aug 2026 18:41:29 +0000 Subject: [PATCH 43/43] Stash warm gossip connections per peer A link stream costs a full attested sprockets handshake, most of a second on hardware with the RoT in the loop. Dials now pop a warm connection established off the critical path, and refills run only while a peer stays active, so an idle rack still makes no handshakes. Fresh handshakes log their latency. --- server/src/link.rs | 300 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 279 insertions(+), 21 deletions(-) diff --git a/server/src/link.rs b/server/src/link.rs index 9fccfd3..88c555e 100644 --- a/server/src/link.rs +++ b/server/src/link.rs @@ -12,18 +12,32 @@ //! 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. +//! 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; +use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; -use std::time::Duration; +use std::time::{Duration, Instant}; use camino::Utf8PathBuf; use rumors::link::routed::{Config, Dial, Endpoint, Incoming, Listen, RoutedLink}; -use slog::{Logger, o, warn}; +use slog::{Logger, info, o, warn}; use sprockets_tls::keys::SprocketsConfig; use sprockets_tls::{Client, Server}; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt as _, ReadBuf}; @@ -111,13 +125,124 @@ impl Drop for SprocketsConn { } } -/// Dials one attested sprockets connection per link stream. +/// 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 { @@ -134,36 +259,93 @@ impl SprocketsDial { config, corpus, timeout, + stash: Arc::new(Mutex::new(Stash::new())), } } -} - -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}"))); - }; + /// 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)(); + 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. + // 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) + Client::connect(config, addr, corpus(), log) .await .map_err(io::Error::other) }); match timeout(self.timeout, dial).await { - Ok(joined) => Ok(SprocketsConn::new(joined.map_err(io::Error::other)??)), + 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, @@ -194,9 +376,16 @@ impl Transport { ) .await?; let dial = SprocketsDial::new(log, config, corpus, dial_timeout); - let (endpoint, incoming, router) = - Endpoint::new(listen, SocketAddr::V6(bound), dial, Config::default()) - .map_err(io::Error::other)?; + 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! { @@ -314,3 +503,72 @@ async fn pump( } } } + +#[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); + } +}