Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 42 additions & 8 deletions client/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -624,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,
};
Expand Down Expand Up @@ -1070,14 +1099,15 @@ async fn job(
));
pin!(sign);
ctx.job_signing_started(&job_id);
let mut sigint = signal(SignalKind::interrupt())?;
let job = loop {
select! {
job = &mut sign => {
ctx.job_signing_finished(&job_id);
break job?;
}
_ = interval.tick() => ctx.job_signing_update(&job_id),
_ = ctrl_c() => {
_ = sigint.recv() => {
ctx.job_signing_finished(&job_id);
return Err(CommandError::Canceled);
}
Expand Down Expand Up @@ -1231,6 +1261,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.
Expand Down Expand Up @@ -1267,7 +1298,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;
}
Expand Down Expand Up @@ -1374,6 +1405,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,
Expand All @@ -1390,7 +1422,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);
Expand Down Expand Up @@ -1815,6 +1847,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}`")]
Expand Down
113 changes: 108 additions & 5 deletions client/src/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<SocketAddrV6>), 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::<u16>())
{
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.
Expand All @@ -55,19 +111,28 @@ pub fn platform_roots() -> Result<Vec<Certificate>, ProxyTlsError> {
}

/// A `reqwest` client that accepts servers whose certificate chains
/// to one of `roots`.
pub fn client(roots: Vec<Certificate>) -> Result<reqwest::Client, ProxyTlsError> {
/// to one of `roots`. With `resolve`, [`SCOPED_HOST`] resolves there.
pub fn client(
roots: Vec<Certificate>,
resolve: Option<SocketAddrV6>,
) -> Result<reqwest::Client, ProxyTlsError> {
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()
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,
Expand Down Expand Up @@ -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());
}
}
13 changes: 8 additions & 5 deletions tests/src/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand Down