From 2a40debe3a1339038b54b1b9714d95a49c68737f Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sat, 15 Aug 2026 05:44:19 +0000 Subject: [PATCH 1/2] Ctrl-C cancels commands Commands that don't handle SIGINT themselves run under a cancelling select, and loop listeners persist across iterations so mid-await interrupts are no longer dropped. Co-Authored-By: Claude Mythos 5 --- client/src/commands.rs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/client/src/commands.rs b/client/src/commands.rs index 904537b..ea54b13 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -29,6 +29,7 @@ use rustix::termios::tcgetwinsize; use sled_hardware_types::BaseboardId; use thiserror::Error; use tokio::signal::ctrl_c; +use tokio::signal::unix::{SignalKind, signal}; use tokio::time::{MissedTickBehavior, interval, sleep}; use tokio::{pin, select}; use tokio_tungstenite::WebSocketStream; @@ -605,8 +606,33 @@ impl JobStartArgs { } impl ClientCommand { + /// Interactive jobs forward SIGINT to the job, watches use it to + /// stop the job or end the watch, and the REPL turns it into a + /// fresh prompt. + fn handles_sigint(&self) -> bool { + matches!( + self, + Self::Shell + | Self::Job { + command: JobCommand::Start { .. } | JobCommand::Attach { .. }, + } + ) + } + + /// Run the command, letting an interrupt cancel it unless the + /// command handles SIGINT itself. #[async_recursion(?Send)] pub async fn execute(self, ctx: &mut impl CommandContext) -> Result<(), CommandError> { + if self.handles_sigint() { + return self.run(ctx).await; + } + select! { + result = self.run(ctx) => result, + _ = ctrl_c() => Err(CommandError::Canceled), + } + } + + async fn run(self, ctx: &mut impl CommandContext) -> Result<(), CommandError> { let args = ctx.get_globals().to_owned(); if let Some(output) = args.output { ctx.set_output_format(output); @@ -1070,6 +1096,7 @@ async fn job( )); pin!(sign); ctx.job_signing_started(&job_id); + let mut sigint = signal(SignalKind::interrupt())?; let job = loop { select! { job = &mut sign => { @@ -1077,7 +1104,7 @@ async fn job( break job?; } _ = interval.tick() => ctx.job_signing_update(&job_id), - _ = ctrl_c() => { + _ = sigint.recv() => { ctx.job_signing_finished(&job_id); return Err(CommandError::Canceled); } @@ -1231,6 +1258,7 @@ async fn job_start( let mut polls = 0; let mut started = false; let mut stopped = false; + let mut sigint = signal(SignalKind::interrupt())?; let status = loop { select! { // Wait for the start request to finish. @@ -1267,7 +1295,7 @@ async fn job_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() => { + _ = sigint.recv() => { if started || stopped { break last; } @@ -1374,6 +1402,7 @@ async fn job_watch( let mut sleds = 0; let mut quiet = 0; let mut polls = 0; + let mut sigint = signal(SignalKind::interrupt())?; let status = loop { let status = match job_status_map(ctx, client, job_id).await { Ok(status) => status, @@ -1390,7 +1419,7 @@ async fn job_watch( sleds = status.len(); select! { _ = sleep(WATCH_INTERVAL) => {} - _ = ctrl_c() => break status, + _ = sigint.recv() => break status, } }; ctx.job_watch_finished(job_id); @@ -1815,6 +1844,8 @@ pub enum CommandError { path: PathBuf, error: std::io::Error, }, + #[error("❌ Signal handling error: {0}")] + Signal(#[from] std::io::Error), #[error("❌ Unauthorized, try `iam`")] InvalidAuthorization, #[error("❌ Leaf certificate does not match key `{0}`")] From f763cde3ca5e88f5a7df4aaa95f4ec2868481bcc Mon Sep 17 00:00:00 2001 From: Alex Plotnick Date: Sat, 15 Aug 2026 06:00:49 +0000 Subject: [PATCH 2/2] Support zone IDs in server URLs URLs cannot carry zone IDs, so `%zone` (or RFC 6874 `%25zone`) in a bracketed IPv6 host maps to a placeholder name that resolves to the scoped socket address. A scopeless link-local URL leaves the kernel guessing the egress interface, and on a multihomed machine the guess can blackhole SYNs for seconds at a time. Co-Authored-By: Claude Mythos 5 --- Cargo.lock | 1 + client/Cargo.toml | 1 + client/src/commands.rs | 13 ++-- client/src/tls.rs | 113 +++++++++++++++++++++++++++++++-- tests/src/integration_tests.rs | 13 ++-- 5 files changed, 126 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fa2607c..8ad1de3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4884,6 +4884,7 @@ dependencies = [ "http-range-header", "humantime", "indicatif", + "libc", "memmap2", "p256", "pem-rfc7468", diff --git a/client/Cargo.toml b/client/Cargo.toml index 11ba06f..946ef9a 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -30,6 +30,7 @@ http.workspace = true http-range-header.workspace = true humantime.workspace = true indicatif.workspace = true +libc.workspace = true memmap2.workspace = true p256.workspace = true pem-rfc7468.workspace = true diff --git a/client/src/commands.rs b/client/src/commands.rs index ea54b13..e826e9a 100644 --- a/client/src/commands.rs +++ b/client/src/commands.rs @@ -650,11 +650,14 @@ impl ClientCommand { } roots }; - Some(Client::new_with_client( - url, - tls::client(roots)?, - ctx.authz_signer(), - )) + { + let (url, resolve) = tls::descope_url(url)?; + Some(Client::new_with_client( + &url, + tls::client(roots, resolve)?, + ctx.authz_signer(), + )) + } } None => None, }; diff --git a/client/src/tls.rs b/client/src/tls.rs index 3899e6e..b867fa6 100644 --- a/client/src/tls.rs +++ b/client/src/tls.rs @@ -9,6 +9,8 @@ //! vouched for. TLS only provides transport privacy for job traffic. //! We do not support expiration, revocation, or server-name binding. +use std::ffi::CString; +use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6}; use std::sync::Arc; use std::time::Duration; @@ -44,6 +46,60 @@ pub enum ProxyTlsError { Rustls(#[from] rustls::Error), #[error("platform identity verifier: {0}")] Verifier(String), + #[error("invalid server URL `{0}`")] + Url(String), + #[error("unknown interface `{0}`")] + Interface(String), +} + +/// Placeholder host for scoped link-local URLs. +const SCOPED_HOST: &str = "link-local.sush"; + +/// Split a `%zone` scope out of a bracketed IPv6 host: URLs cannot +/// carry zone IDs, so the address maps to [`SCOPED_HOST`] and the +/// zone becomes the scope ID of the resolved socket address. A +/// numeric zone starting with `25` needs the RFC 6874 `%25` prefix. +pub fn descope_url(url: &str) -> Result<(String, Option), ProxyTlsError> { + let (Some(open), Some(close)) = (url.find('['), url.find(']')) else { + return Ok((url.to_string(), None)); + }; + let Some((addr, zone)) = url.get(open + 1..close).and_then(|h| h.split_once('%')) else { + return Ok((url.to_string(), None)); + }; + // RFC 6874 escapes the `%` itself as `%25`. + let zone = zone + .strip_prefix("25") + .filter(|z| !z.is_empty()) + .unwrap_or(zone); + let ip: Ipv6Addr = addr + .parse() + .map_err(|_| ProxyTlsError::Url(url.to_string()))?; + let scope = match zone.parse() { + // Zone 0 is no zone; the kernel would be back to guessing. + Ok(0) => return Err(ProxyTlsError::Url(url.to_string())), + Ok(scope) => scope, + Err(_) => { + let name = CString::new(zone).map_err(|_| ProxyTlsError::Url(url.to_string()))?; + match unsafe { libc::if_nametoindex(name.as_ptr()) } { + 0 => return Err(ProxyTlsError::Interface(zone.to_string())), + scope => scope, + } + } + }; + let rest = &url[close + 1..]; + let port = match rest + .strip_prefix(':') + .map(|r| r.split(['/', '?']).next().unwrap_or(r).parse::()) + { + Some(Ok(port)) => port, + Some(Err(_)) => return Err(ProxyTlsError::Url(url.to_string())), + None if url[..open].starts_with("http:") => 80, + None => 443, + }; + Ok(( + format!("{}{SCOPED_HOST}{rest}", &url[..open]), + Some(SocketAddrV6::new(ip, port, 0, scope)), + )) } /// The baked-in platform roots. @@ -55,8 +111,11 @@ pub fn platform_roots() -> Result, ProxyTlsError> { } /// A `reqwest` client that accepts servers whose certificate chains -/// to one of `roots`. -pub fn client(roots: Vec) -> Result { +/// to one of `roots`. With `resolve`, [`SCOPED_HOST`] resolves there. +pub fn client( + roots: Vec, + resolve: Option, +) -> 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())) @@ -64,10 +123,16 @@ pub fn client(roots: Vec) -> Result .dangerous() .with_custom_certificate_verifier(Arc::new(PlatformVerifier { inner })) .with_no_client_auth(); - Ok(reqwest::Client::builder() + let mut builder = reqwest::Client::builder() .use_preconfigured_tls(config) - .timeout(TIMEOUT) - .build()?) + .timeout(TIMEOUT); + if let Some(addr) = resolve { + // An external proxy would bypass the override entirely. + builder = builder + .resolve(SCOPED_HOST, SocketAddr::V6(addr)) + .no_proxy(); + } + Ok(builder.build()?) } /// Accept a certificate chain that is the platform identity itself, @@ -147,3 +212,41 @@ fn verify_delegated_leaf( key.verify_strict(&Sha3_256::digest(&tbs), &signature) .map_err(|_| bad) } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn descoped_urls() { + let (url, resolve) = descope_url("https://[fe80::1%3]:12352").unwrap(); + assert_eq!(url, "https://link-local.sush:12352"); + let addr = SocketAddrV6::new("fe80::1".parse().unwrap(), 12352, 0, 3); + assert_eq!(resolve, Some(addr)); + + let (url, resolve) = descope_url("https://[fe80::1%3]").unwrap(); + assert_eq!(url, "https://link-local.sush"); + assert_eq!(resolve.unwrap().port(), 443); + + let (url, resolve) = descope_url("https://[fe80::1%253]:12352").unwrap(); + assert_eq!(url, "https://link-local.sush:12352"); + assert_eq!(resolve.unwrap().scope_id(), 3); + + let (url, resolve) = descope_url("https://[fe80::1%3]:12352/some/path?q=1").unwrap(); + assert_eq!(url, "https://link-local.sush:12352/some/path?q=1"); + assert_eq!(resolve.unwrap().port(), 12352); + + let (url, resolve) = descope_url("https://[fdb0::1]:12352").unwrap(); + assert_eq!(url, "https://[fdb0::1]:12352"); + assert_eq!(resolve, None); + + let (url, resolve) = descope_url("https://permslip.example").unwrap(); + assert_eq!(url, "https://permslip.example"); + assert_eq!(resolve, None); + + assert!(descope_url("https://[nonsense%3]:1").is_err()); + assert!(descope_url("https://[fe80::1%0]:1").is_err()); + assert!(descope_url("https://[fe80::1%nosuchif0]:1").is_err()); + assert!(descope_url("https://[fe80::1%3]:notaport").is_err()); + } +} diff --git a/tests/src/integration_tests.rs b/tests/src/integration_tests.rs index f11df1c..434c991 100644 --- a/tests/src/integration_tests.rs +++ b/tests/src/integration_tests.rs @@ -326,7 +326,7 @@ async fn client_tls_proxy_server() { 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 client = Client::new_with_client(&url, tls_client(roots, None).unwrap(), signer.clone()); let ClientError::ErrorResponse(unauthz) = client.iam().body(None).send().await.unwrap_err() else { panic!("expected error response") @@ -347,8 +347,11 @@ async fn client_tls_proxy_server() { 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()); + let stranger = Client::new_with_client( + &url, + tls_client(strangers, None).unwrap(), + AuthzSigner::default(), + ); assert!(stranger.iam().body(None).send().await.is_err()); // A second proxy serves an ephemeral leaf the platform identity @@ -401,7 +404,7 @@ async fn client_tls_proxy_server() { let roots = vec![Certificate::from_pem(&pem).unwrap()]; let client2 = Client::new_with_client( &format!("https://{}", proxy2.local_addr()), - tls_client(roots.clone()).unwrap(), + tls_client(roots.clone(), None).unwrap(), signer.clone(), ); let iam = client2 @@ -454,7 +457,7 @@ async fn client_tls_proxy_server() { .expect("can't start forged proxy server"); let client3 = Client::new_with_client( &format!("https://{}", proxy3.local_addr()), - tls_client(roots).unwrap(), + tls_client(roots, None).unwrap(), signer.clone(), ); assert!(client3.iam().body(None).send().await.is_err());