pub mod complete;
pub mod exec;
pub mod files;
pub mod http;
pub mod keepalive;
pub mod proxy;
pub mod split;
pub mod sse;
use std::sync::Arc;
use std::time::Duration;
pub use complete::{
ClientDeadline, CompletionOptions, CompletionPlan, DEFAULT_CLIENT_GRACE, KillAnswer,
NO_TIMEOUT_CEILING, OutputFlow, OutputSink,
};
pub use exec::{
DEFAULT_EXEC_WAIT, EndReason, ExecHandle, ExecResult, StreamEnd, StreamOptions,
TIMED_OUT_EXIT_CODE, exec_id_at,
};
pub use http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream, SharedBackend};
pub use keepalive::{KeepAwake, KeepAwakeEnd, KeepAwakeReport, KeepAwakeTask, RunningGate};
pub use proxy::{
Clock, DEFAULT_AGENT_PORT, DEFAULT_REFRESH_AFTER, MAX_TOKEN_LIFETIME, PROXY_AUTH_HEADER,
PROXY_PORT_HEADER, ProxyAuth, ProxyToken, TokenMinter, WS_AUTH_SUBPROTOCOL_PREFIX,
WS_PORT_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL,
};
pub use split::{GapLog, GapPolicy, OutputGap, Split, SplitItem};
pub use sse::{ExecEvent, Frame, SseParser};
use crate::error::{Error, ErrorKind, WireKind};
pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
pub const DEFAULT_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(120);
#[deprecated(
note = "renamed DEFAULT_BOOTSTRAP_TIMEOUT: `sandbox::DEFAULT_READY_TIMEOUT` shared this name \
for a different wait, the one for RUNNING"
)]
pub const DEFAULT_READY_TIMEOUT: Duration = DEFAULT_BOOTSTRAP_TIMEOUT;
const READY_POLL_INTERVAL: Duration = Duration::from_secs(2);
pub struct Transport {
backend: http::SharedBackend,
agent_token: String,
proxy: Option<Arc<ProxyAuth>>,
timeout: Duration,
}
impl Transport {
pub fn proxy(&self) -> Option<&Arc<ProxyAuth>> {
self.proxy.as_ref()
}
async fn headers(&self, token: Option<Option<&str>>) -> Result<Vec<(String, String)>, Error> {
let mut headers = match &self.proxy {
Some(proxy) => proxy.headers().await?,
None => Vec::new(),
};
let bearer = match token {
Some(explicit) => explicit,
None => Some(self.agent_token.as_str()),
};
if let Some(bearer) = bearer {
headers.push(("Authorization".to_string(), format!("Bearer {bearer}")));
}
Ok(headers)
}
async fn request(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
let timeout = request.timeout.unwrap_or(self.timeout);
let method = request.method;
let path = request.path.clone();
tokio::time::timeout(timeout, self.request_inner(request))
.await
.map_err(|_| Error::wire(WireKind::Transport,
format!("{method} {path} exceeded its request timeout (including proxy authentication)")))?
}
async fn request_inner(&self, mut request: HttpRequest) -> Result<HttpResponse, Error> {
let mut headers = self.headers(request_token(&request)).await?;
request.headers.retain(|(name, _)| name != TOKEN_INTENT);
headers.append(&mut request.headers);
request.headers = headers;
if request.timeout.is_none() {
request.timeout = Some(self.timeout);
}
self.backend.send(request).await
}
async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
let (method, path) = (request.method, request.path.clone());
let response = self.request(request).await?;
response.error_for_status(method, &path)?;
Ok(response)
}
async fn send_json<T: serde::de::DeserializeOwned>(
&self,
request: HttpRequest,
) -> Result<T, Error> {
let (method, path) = (request.method, request.path.clone());
let response = self.send(request).await?;
serde_json::from_slice(&response.body).map_err(|err| {
Error::wire(
WireKind::ProtocolError,
format!(
"{method} {path} answered a body this client cannot read: {err}; \
check /v1/schema for the daemon's protocol version"
),
)
.with_source(err)
})
}
}
const TOKEN_INTENT: &str = "x-microvms-core-token-intent";
const TOKEN_INTENT_NONE: &str = "none";
fn request_token(request: &HttpRequest) -> Option<Option<&str>> {
match request.header(TOKEN_INTENT) {
None => None,
Some(TOKEN_INTENT_NONE) => Some(None),
Some(explicit) => Some(Some(explicit)),
}
}
fn unauthenticated(mut request: HttpRequest) -> HttpRequest {
request
.headers
.push((TOKEN_INTENT.to_string(), TOKEN_INTENT_NONE.to_string()));
request
}
#[derive(Clone)]
pub struct Session {
transport: Arc<Transport>,
endpoint: String,
port: u16,
egress_posture: crate::control::EgressPosture,
clock: Arc<dyn Clock>,
}
impl Session {
pub fn agent_token(&self) -> &str {
&self.transport.agent_token
}
pub fn builder(endpoint: impl Into<String>, agent_token: impl Into<String>) -> SessionBuilder {
SessionBuilder {
endpoint: endpoint.into(),
agent_token: agent_token.into(),
minter: None,
backend: None,
port: DEFAULT_AGENT_PORT,
timeout: DEFAULT_REQUEST_TIMEOUT,
proxy: None,
egress_posture: crate::control::EgressPosture::default(),
clock: None,
}
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
pub(crate) fn mint_exec_id(&self) -> String {
exec_id_at(self.clock.unix_now())
}
pub fn egress_posture(&self) -> crate::control::EgressPosture {
self.egress_posture
}
pub fn port(&self) -> u16 {
self.port
}
pub fn proxy_auth(&self) -> Option<&Arc<ProxyAuth>> {
self.transport.proxy()
}
pub async fn connect_headers(&self, port: u16) -> Result<Vec<(String, String)>, Error> {
match self.transport.proxy() {
Some(proxy) => {
tokio::time::timeout(self.transport.timeout, proxy.headers_for_port(port))
.await
.map_err(|_| {
Error::wire(
WireKind::Transport,
"proxy authentication request timed out",
)
})?
}
None => Ok(Vec::new()),
}
}
pub async fn connect_subprotocols(&self, port: u16) -> Result<Option<[String; 3]>, Error> {
match self.transport.proxy() {
Some(proxy) => Ok(Some(
tokio::time::timeout(self.transport.timeout, proxy.subprotocols(port))
.await
.map_err(|_| {
Error::wire(
WireKind::Transport,
"proxy authentication request timed out",
)
})??,
)),
None => Ok(None),
}
}
pub fn rebind(&mut self, endpoint: String) {
self.endpoint = endpoint;
if let Some(proxy) = self.transport.proxy() {
proxy.invalidate();
}
}
pub async fn health(&self) -> Result<protocol::health::Health, Error> {
self.transport
.send_json(unauthenticated(HttpRequest::new("GET", "/v1/health")))
.await
}
pub async fn keep_awake(
&self,
policy: &KeepAwake,
stop: impl std::future::Future<Output = ()>,
) -> Result<KeepAwakeReport, Error> {
policy
.run(|| async { self.health().await.map(Some) }, stop)
.await
}
pub async fn wait_until_ready(
&self,
timeout: Duration,
) -> Result<protocol::health::Health, Error> {
let deadline = deadline_after(timeout);
let mut last: Option<Error> = None;
loop {
match self.health().await {
Ok(health) if health.bootstrapped => return Ok(health),
Ok(_) => {}
Err(err) if err.retryable() => last = Some(err),
Err(err) => return Err(err),
}
let now = tokio::time::Instant::now();
if now >= deadline {
let detail = match last {
Some(err) => format!(" (last error: {err})"),
None => String::new(),
};
return Err(Error::new(
ErrorKind::Timeout,
format!(
"the daemon was not bootstrapped within {}s{detail}",
timeout.as_secs()
),
));
}
tokio::time::sleep(READY_POLL_INTERVAL.min(deadline - now)).await;
}
}
pub async fn run(&self, req: protocol::exec::StartRequest) -> Result<ExecHandle, Error> {
let requested = req.exec_id.clone();
let mut request = HttpRequest::new("POST", "/v1/exec/start");
request
.headers
.push(("content-type".into(), "application/json".into()));
request.body = serde_json::to_vec(&req).map_err(|err| {
Error::invalid_arg(format!("the start request will not serialize: {err}"))
})?;
let started: protocol::exec::StartResponse = self.transport.send_json(request).await?;
let exec_id = if started.exec_id.is_empty() {
requested
} else {
started.exec_id
};
Ok(ExecHandle::new(Arc::clone(&self.transport), exec_id))
}
pub fn exec(&self, exec_id: impl Into<String>) -> ExecHandle {
ExecHandle::new(Arc::clone(&self.transport), exec_id.into())
}
pub async fn run_sync(
&self,
req: protocol::exec::StartRequest,
timeout: Duration,
) -> Result<ExecResult, Error> {
self.run(req).await?.wait_and_ack(timeout).await
}
pub async fn kill(&self, exec_id: &str) -> Result<bool, Error> {
self.exec(exec_id).kill().await
}
pub async fn procs(&self) -> Result<protocol::exec::ProcsResponse, Error> {
self.transport
.send_json(HttpRequest::new("GET", "/v1/procs"))
.await
}
pub async fn upload_file(
&self,
path: &str,
data: &[u8],
mode: Option<&str>,
) -> Result<(), Error> {
files::upload_file(&self.transport, path, data, mode).await
}
pub async fn download_file(&self, path: &str) -> Result<Vec<u8>, Error> {
files::download_file(&self.transport, path).await
}
pub async fn download_file_lines(
&self,
path: &str,
start_line: Option<u64>,
end_line: Option<u64>,
) -> Result<Vec<u8>, Error> {
files::download_file_lines(&self.transport, path, start_line, end_line).await
}
pub async fn file_exists(&self, path: &str) -> Result<bool, Error> {
files::file_exists(&self.transport, path).await
}
pub async fn upload_tar(&self, remote: &str, archive: &[u8]) -> Result<(), Error> {
files::upload_tar(&self.transport, remote, archive).await
}
pub async fn download_tar(&self, remote: &str) -> Result<Vec<u8>, Error> {
files::download_tar(&self.transport, remote).await
}
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session")
.field("endpoint", &self.endpoint)
.field("port", &self.port)
.field("proxy", &self.transport.proxy())
.finish()
}
}
pub struct SessionBuilder {
endpoint: String,
agent_token: String,
minter: Option<Arc<dyn TokenMinter>>,
backend: Option<http::SharedBackend>,
port: u16,
timeout: Duration,
proxy: Option<Arc<ProxyAuth>>,
egress_posture: crate::control::EgressPosture,
clock: Option<Arc<dyn Clock>>,
}
impl SessionBuilder {
#[must_use]
pub fn with_egress_posture(mut self, posture: crate::control::EgressPosture) -> Self {
self.egress_posture = posture;
self
}
#[must_use]
pub fn with_minter(mut self, minter: Arc<dyn TokenMinter>) -> Self {
self.minter = Some(minter);
self
}
#[must_use]
pub fn with_proxy_auth(mut self, proxy: Arc<ProxyAuth>) -> Self {
self.proxy = Some(proxy);
self
}
#[must_use]
pub fn with_backend(mut self, backend: http::SharedBackend) -> Self {
self.backend = Some(backend);
self
}
#[must_use]
pub fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
self.clock = Some(clock);
self
}
pub fn build_with(
mut self,
adapters: &dyn crate::adapters::Adapters,
) -> Result<Session, Error> {
if self.backend.is_none() {
self.backend = Some(adapters.http_backend(&self.endpoint, self.timeout)?);
}
if self.clock.is_none() {
self.clock = Some(adapters.clock());
}
self.try_build()
}
pub fn try_build(self) -> Result<Session, Error> {
let (Some(backend), Some(clock)) = (self.backend, self.clock) else {
return Err(Error::new(
ErrorKind::Precondition,
"a session needs a backend and a clock: set both with with_backend and \
with_clock, or build through the prelude's SessionBuilder::build",
));
};
let proxy = match (self.proxy, self.minter) {
(Some(proxy), _) => Some(proxy),
(None, Some(minter)) => Some(Arc::new(ProxyAuth::with_clock(
minter,
self.port,
Arc::clone(&clock),
))),
(None, None) => None,
};
Ok(Session {
transport: Arc::new(Transport {
backend,
agent_token: self.agent_token,
proxy,
timeout: self.timeout,
}),
endpoint: self.endpoint,
port: self.port,
egress_posture: self.egress_posture,
clock,
})
}
}
const FAR_FUTURE: Duration = Duration::from_secs(946_080_000);
pub(crate) fn deadline_after(after: Duration) -> tokio::time::Instant {
let now = tokio::time::Instant::now();
now.checked_add(after).unwrap_or_else(|| now + FAR_FUTURE)
}
#[cfg(any(test, feature = "test-support"))]
#[cfg_attr(test, mutants::skip)] pub(crate) mod testing {
use std::collections::HashMap;
use std::sync::{Mutex, PoisonError};
use futures_util::future::BoxFuture;
use super::http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream};
use super::*;
pub enum Reply {
Body(u16, Vec<u8>),
Chunks(u16, Vec<Vec<u8>>),
Cut(&'static str),
Stalled(Vec<Vec<u8>>),
}
impl Reply {
pub fn json(status: u16, body: impl serde::Serialize) -> Self {
Reply::Body(status, serde_json::to_vec(&body).expect("test body"))
}
pub fn ok(body: impl serde::Serialize) -> Self {
Reply::json(200, body)
}
}
#[derive(Default)]
pub struct Recorder {
seen: Mutex<Vec<HttpRequest>>,
replies: Mutex<std::collections::VecDeque<Reply>>,
}
impl Recorder {
pub fn with(replies: impl IntoIterator<Item = Reply>) -> Arc<Self> {
Arc::new(Self {
seen: Mutex::new(Vec::new()),
replies: Mutex::new(replies.into_iter().collect()),
})
}
pub fn requests(&self) -> Vec<HttpRequest> {
self.seen
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub fn last(&self) -> HttpRequest {
self.requests()
.pop()
.expect("the recorder saw no request at all")
}
fn record(&self, request: HttpRequest) -> Reply {
self.seen
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(request.clone());
self.replies
.lock()
.unwrap_or_else(PoisonError::into_inner)
.pop_front()
.unwrap_or_else(|| {
panic!(
"the recorder ran out of replies at {} {}",
request.method, request.path
)
})
}
}
struct Queued(std::collections::VecDeque<Vec<u8>>);
impl ChunkSource for Queued {
fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
Box::pin(async move { Ok(self.0.pop_front()) })
}
}
struct Stalling(std::collections::VecDeque<Vec<u8>>);
impl ChunkSource for Stalling {
fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
Box::pin(async move {
match self.0.pop_front() {
Some(chunk) => Ok(Some(chunk)),
None => std::future::pending().await,
}
})
}
}
impl HttpBackend for Recorder {
fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
let reply = self.record(request);
Box::pin(async move {
match reply {
Reply::Body(status, body) => Ok(HttpResponse {
status,
headers: HashMap::new(),
body,
}),
Reply::Chunks(status, chunks) => Ok(HttpResponse {
status,
headers: HashMap::new(),
body: chunks.concat(),
}),
Reply::Stalled(chunks) => Ok(HttpResponse {
status: 200,
headers: HashMap::new(),
body: chunks.concat(),
}),
Reply::Cut(why) => Err(Error::wire(WireKind::Transport, why)),
}
})
}
fn open_stream(
&self,
request: HttpRequest,
_idle_timeout: Duration,
) -> BoxFuture<'_, Result<OpenStream, Error>> {
let reply = self.record(request);
Box::pin(async move {
let (status, chunks) = match reply {
Reply::Chunks(status, chunks) => (status, chunks),
Reply::Body(status, body) => (status, vec![body]),
Reply::Cut(why) => return Err(Error::wire(WireKind::Transport, why)),
Reply::Stalled(chunks) => {
let head = HttpResponse {
status: 200,
headers: HashMap::new(),
body: Vec::new(),
};
let source: Box<dyn ChunkSource> =
Box::new(Stalling(chunks.into_iter().collect()));
return Ok((head, source));
}
};
let head = HttpResponse {
status,
headers: HashMap::new(),
body: if (200..300).contains(&status) {
Vec::new()
} else {
chunks.concat()
},
};
let source: Box<dyn ChunkSource> = if (200..300).contains(&status) {
Box::new(Queued(chunks.into_iter().collect()))
} else {
Box::new(Queued(std::collections::VecDeque::new()))
};
Ok((head, source))
})
}
}
pub fn session_with(
recorder: Arc<Recorder>,
) -> (
Session,
Arc<ProxyAuth>,
Arc<crate::clock::testing::TestClock>,
) {
let clock = Arc::new(crate::clock::testing::TestClock::default());
let auth = Arc::new(
ProxyAuth::with_refresh_after(
Arc::new(proxy::testing::CountingMinter::default()),
DEFAULT_AGENT_PORT,
DEFAULT_REFRESH_AFTER,
Arc::clone(&clock) as Arc<dyn Clock>,
)
.expect("the default interval is below the ceiling"),
);
let session = Session::builder("https://vm.example", "agent-token-abcdef")
.with_backend(recorder)
.with_proxy_auth(Arc::clone(&auth))
.with_clock(Arc::clone(&clock) as Arc<dyn Clock>)
.try_build()
.expect("builds");
(session, auth, clock)
}
pub fn health_body(bootstrapped: bool) -> serde_json::Value {
serde_json::json!({
"version": "0.1.0",
"bootstrapped": bootstrapped,
"disk": null,
"identity_degraded": false,
"identity_repaired": true,
})
}
#[derive(Default)]
pub struct HealthyDaemon {
polls: std::sync::atomic::AtomicUsize,
}
impl HealthyDaemon {
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
pub fn polls(&self) -> usize {
self.polls.load(std::sync::atomic::Ordering::SeqCst)
}
fn refuse(request: &HttpRequest) -> Error {
Error::wire(
WireKind::Transport,
format!(
"{} {} reached a test daemon that answers only health",
request.method, request.path
),
)
}
}
impl HttpBackend for HealthyDaemon {
fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
let answer = if request.method == "GET" && request.path == "/v1/health" {
self.polls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(HttpResponse {
status: 200,
headers: HashMap::new(),
body: health_body(true).to_string().into_bytes(),
})
} else {
Err(Self::refuse(&request))
};
Box::pin(async move { answer })
}
fn open_stream(
&self,
request: HttpRequest,
_idle_timeout: Duration,
) -> BoxFuture<'_, Result<OpenStream, Error>> {
let refused = Self::refuse(&request);
Box::pin(async move { Err(refused) })
}
}
}
#[cfg(test)]
mod tests {
use super::testing::{Recorder, Reply, health_body, session_with};
use super::*;
use crate::session::proxy::testing::CountingMinter;
fn start_request(exec_id: &str) -> protocol::exec::StartRequest {
protocol::exec::StartRequest::new(exec_id, vec!["/bin/true".to_string()])
}
fn header(request: &HttpRequest, name: &str) -> Option<String> {
request.header(name).map(str::to_string)
}
fn header_of(headers: &[(String, String)], name: &str) -> Option<String> {
headers
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(name))
.map(|(_, value)| value.clone())
}
#[test]
fn try_build_needs_a_backend_and_a_clock() {
let clock = Arc::new(crate::clock::testing::TestClock::new()) as Arc<dyn Clock>;
let no_backend = Session::builder("https://vm.example", "token")
.with_clock(Arc::clone(&clock))
.try_build()
.expect_err("no backend");
assert_eq!(no_backend.kind(), ErrorKind::Precondition);
let no_clock = Session::builder("https://vm.example", "token")
.with_backend(Recorder::with([]))
.try_build()
.expect_err("no clock");
assert_eq!(no_clock.kind(), ErrorKind::Precondition);
Session::builder("https://vm.example", "token")
.with_backend(Recorder::with([]))
.with_clock(clock)
.try_build()
.expect("both set");
}
#[tokio::test]
async fn build_with_keeps_the_callers_backend() {
let recorder = Recorder::with([Reply::ok(health_body(true))]);
let session = Session::builder("http://127.0.0.1:9000", "token")
.with_backend(Arc::clone(&recorder) as http::SharedBackend)
.build_with(&crate::testing::TestAdapters::new())
.expect("builds");
session.health().await.expect("the recorder answered");
assert_eq!(recorder.requests().len(), 1);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_also_bounds_a_blocked_proxy_mint() {
struct BlockedMinter;
impl TokenMinter for BlockedMinter {
fn mint(&self) -> futures_util::future::BoxFuture<'_, Result<ProxyToken, Error>> {
Box::pin(std::future::pending())
}
}
let backend = Recorder::with([]);
let session = Session::builder("http://127.0.0.1:9", "private")
.with_backend(backend.clone())
.with_minter(Arc::new(BlockedMinter))
.with_timeout(Duration::from_secs(3))
.build_with(&crate::testing::TestAdapters::new())
.expect("session");
let result = tokio::time::timeout(Duration::from_secs(4), session.health())
.await
.expect("the request budget must include a stalled proxy mint")
.expect_err("mint never completed");
assert_eq!(result.wire_kind(), Some(WireKind::Transport));
assert!(result.retryable());
let result = tokio::time::timeout(Duration::from_secs(4), session.connect_headers(9000))
.await
.expect("external transfers must bound authentication too")
.expect_err("mint remains blocked");
assert!(result.retryable());
let result =
tokio::time::timeout(Duration::from_secs(4), session.connect_subprotocols(9000))
.await
.expect("external sockets must bound authentication too")
.expect_err("mint remains blocked");
assert!(result.retryable());
assert!(
backend.requests().is_empty(),
"no request could authenticate"
);
}
#[tokio::test]
async fn a_callers_content_type_survives_the_auth_header_injection() {
let recorder = Recorder::with([Reply::ok(
serde_json::json!({"exec_id":"e1","phase":"running"}),
)]);
let (session, _, _) = session_with(Arc::clone(&recorder));
session.run(start_request("e1")).await.expect("start");
let seen = recorder.requests();
assert_eq!(
header(&seen[0], "content-type").as_deref(),
Some("application/json"),
"the exec start lost its content type to the auth header injection"
);
}
#[tokio::test]
async fn the_session_hands_back_the_token_it_authenticates_with() {
let recorder = Recorder::with([]);
let (session, _, _) = session_with(Arc::clone(&recorder));
assert_eq!(session.agent_token(), "agent-token-abcdef");
}
#[tokio::test]
async fn every_endpoint_request_carries_both_proxy_headers() {
let recorder = Recorder::with([
Reply::ok(health_body(true)),
Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
session.health().await.expect("health succeeds");
session.run(start_request("e1")).await.expect("start");
let seen = recorder.requests();
assert_eq!(seen.len(), 2);
for request in &seen {
assert_eq!(
header(request, PROXY_AUTH_HEADER).as_deref(),
Some(CountingMinter::value(0).as_str()),
"{} {} went out without the proxy auth header",
request.method,
request.path
);
assert_eq!(
header(request, PROXY_PORT_HEADER).as_deref(),
Some("9000"),
"{} {} went out without the proxy port header, which the proxy \
rejects as if the token were bad",
request.method,
request.path
);
}
}
#[tokio::test]
async fn health_sends_no_bearer_and_an_exec_route_does() {
let recorder = Recorder::with([
Reply::ok(health_body(true)),
Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
session.health().await.expect("health");
let seen = recorder.requests();
assert_eq!(header(&seen[0], "authorization"), None);
assert_eq!(
header(&seen[0], "x-microvms-core-token-intent"),
None,
"the internal token-intent marker must not reach the wire"
);
session.run(start_request("e1")).await.expect("start");
assert_eq!(
header(&recorder.last(), "authorization").as_deref(),
Some("Bearer agent-token-abcdef")
);
}
#[tokio::test(start_paused = true)]
async fn keep_awake_polls_health_through_the_proxy_until_the_vm_is_idle() {
let busy = |busy: bool| {
let mut body = health_body(true);
body["busy"] = serde_json::json!(busy);
Reply::ok(body)
};
let recorder = Recorder::with([busy(true), busy(true), busy(false)]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let report = session
.clone()
.keep_awake(
&KeepAwake::new(None).while_busy(true),
std::future::pending(),
)
.await
.expect("keepalive");
assert_eq!((report.end, report.polls), (KeepAwakeEnd::Idle, 3));
let seen = recorder.requests();
assert_eq!(seen.len(), 3);
for request in &seen {
assert_eq!(
(request.method, request.path.as_str()),
("GET", "/v1/health")
);
assert_eq!(header(request, "authorization"), None);
assert!(header(request, PROXY_AUTH_HEADER).is_some());
assert!(header(request, PROXY_PORT_HEADER).is_some());
}
}
#[tokio::test]
async fn a_direct_session_sends_no_proxy_headers() {
let recorder = Recorder::with([Reply::ok(health_body(true))]);
let session = Session::builder("http://127.0.0.1:9000", "token")
.with_backend(Arc::clone(&recorder) as http::SharedBackend)
.build_with(&crate::testing::TestAdapters::new())
.expect("builds");
assert!(session.proxy_auth().is_none());
session.health().await.expect("health");
let request = recorder.last();
assert_eq!(header(&request, PROXY_AUTH_HEADER), None);
assert_eq!(header(&request, PROXY_PORT_HEADER), None);
}
#[tokio::test]
async fn a_mint_failure_surfaces_as_a_retryable_request_error() {
let recorder = Recorder::with([Reply::ok(health_body(true))]);
let auth = Arc::new(
ProxyAuth::with_refresh_after(
Arc::new(CountingMinter::failing(1)),
DEFAULT_AGENT_PORT,
DEFAULT_REFRESH_AFTER,
Arc::new(proxy::testing::TestClock::default()),
)
.expect("interval accepted"),
);
let session = Session::builder("https://vm.example", "token")
.with_backend(Arc::clone(&recorder) as http::SharedBackend)
.with_proxy_auth(Arc::clone(&auth))
.build_with(&crate::testing::TestAdapters::new())
.expect("builds");
let err = session.health().await.expect_err("the mint fails");
assert_eq!(err.kind(), ErrorKind::Retryable);
assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
assert!(
recorder.requests().is_empty(),
"a request went out without a token"
);
session.health().await.expect("the retry succeeds");
assert_eq!(auth.mint_count(), 1);
}
#[tokio::test]
async fn a_daemon_status_arrives_as_its_typed_error() {
for (status, expected) in [
(401, WireKind::Unauthorized),
(404, WireKind::NotFound),
(409, WireKind::Conflict),
(503, WireKind::NotBootstrapped),
] {
let recorder = Recorder::with([Reply::Body(status, b"{\"error\":\"x\"}".to_vec())]);
let (session, _, _) = session_with(recorder);
let err = session.health().await.expect_err("a failing status");
assert_eq!(err.wire_kind(), Some(expected), "status {status}");
}
}
#[tokio::test(start_paused = true)]
async fn wait_until_ready_polls_through_not_bootstrapped_and_a_dropped_connection() {
let recorder = Recorder::with([
Reply::Cut("connection refused"),
Reply::Body(503, b"not bootstrapped".to_vec()),
Reply::ok(health_body(false)),
Reply::ok(health_body(true)),
]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let health = session
.wait_until_ready(DEFAULT_BOOTSTRAP_TIMEOUT)
.await
.expect("the daemon comes up");
assert!(health.bootstrapped);
assert_eq!(recorder.requests().len(), 4);
}
#[tokio::test(start_paused = true)]
async fn wait_until_ready_gives_up_at_once_on_a_credential_failure() {
let recorder = Recorder::with([Reply::Body(401, b"wrong token".to_vec())]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let err = session
.wait_until_ready(DEFAULT_BOOTSTRAP_TIMEOUT)
.await
.expect_err("a 401 is fatal");
assert_eq!(err.kind(), ErrorKind::Credentials);
assert_eq!(
recorder.requests().len(),
1,
"a fatal error was retried, so a wrong token costs the full timeout"
);
}
#[tokio::test(start_paused = true)]
async fn wait_until_ready_times_out_naming_the_last_retryable_error() {
let recorder = Recorder::with([
Reply::Cut("connection refused"),
Reply::Cut("connection refused"),
Reply::Cut("connection refused"),
]);
let (session, _, _) = session_with(recorder);
let err = session
.wait_until_ready(Duration::from_secs(4))
.await
.expect_err("the daemon never comes up");
assert_eq!(err.kind(), ErrorKind::Timeout);
assert!(
err.to_string().contains("connection refused"),
"the timeout must name what it kept hitting: {err}"
);
}
#[tokio::test]
async fn rebind_invalidates_the_proxy_token_so_the_next_request_mints() {
let recorder = Recorder::with([Reply::ok(health_body(true)), Reply::ok(health_body(true))]);
let (mut session, auth, _) = session_with(Arc::clone(&recorder));
session.health().await.expect("health");
assert_eq!(auth.mint_count(), 1);
session.rebind("https://vm.example".to_string());
assert!(!auth.is_cached(), "rebind did not drop the token");
session.health().await.expect("health");
assert_eq!(auth.mint_count(), 2);
assert_eq!(
header(&recorder.last(), PROXY_AUTH_HEADER).as_deref(),
Some(CountingMinter::value(1).as_str()),
"the request after a rebind carried the pre-suspend token"
);
}
#[tokio::test]
async fn a_start_request_serializes_through_the_protocol_crate() {
let recorder = Recorder::with([Reply::ok(
serde_json::json!({"exec_id":"e-confirmed","phase":"running"}),
)]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let handle = session.run(start_request("e-asked")).await.expect("start");
assert_eq!(
handle.exec_id(),
"e-confirmed",
"the handle addresses the id the client asked for rather than the one the \
daemon confirmed, so every later call would 404 if they diverged"
);
let request = recorder.last();
assert_eq!(request.method, "POST");
assert_eq!(request.path, "/v1/exec/start");
let body: serde_json::Value = serde_json::from_slice(&request.body).expect("json body");
assert_eq!(body["exec_id"], "e-asked");
assert_eq!(body["command"], serde_json::json!(["/bin/true"]));
}
#[tokio::test]
async fn an_unreadable_response_body_names_the_schema_route() {
let recorder = Recorder::with([Reply::Body(200, b"{\"unexpected\":true}".to_vec())]);
let (session, _, _) = session_with(recorder);
let err = session.health().await.expect_err("the body will not parse");
assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
assert!(err.to_string().contains("/v1/schema"), "{err}");
}
#[tokio::test]
async fn a_session_answers_connect_credentials_for_a_port_off_its_own_token_cache() {
let recorder = Recorder::with([Reply::ok(health_body(true))]);
let (session, auth, _) = session_with(Arc::clone(&recorder));
session.health().await.expect("health warms the cache");
assert_eq!(auth.mint_count(), 1);
let headers = session
.connect_headers(8080)
.await
.expect("the cached token answers");
let offered = session
.connect_subprotocols(8080)
.await
.expect("the cached token answers")
.expect("a proxied session offers subprotocols");
assert_eq!(
header_of(&headers, PROXY_PORT_HEADER).as_deref(),
Some("8080"),
"the caller asked for 8080 and would have targeted the session's port"
);
assert_eq!(
header_of(&headers, PROXY_AUTH_HEADER).as_deref(),
Some(CountingMinter::value(1).as_str()),
"the header pair carried the agent-port-only token, which the proxy refuses for \
8080 with 403 'Access to port denied'"
);
assert_eq!(offered[0], proxy::WS_SUBPROTOCOL);
assert_eq!(
offered[1],
format!(
"{}{}",
proxy::WS_AUTH_SUBPROTOCOL_PREFIX,
CountingMinter::value(1)
),
"the handshake carried a different token than the header path, so they are two \
caches rather than one"
);
assert_eq!(offered[2], "lambda-microvms.port.8080");
assert_eq!(
auth.mint_count(),
2,
"one mint covered both the agent port and 8080, which the control plane does not \
do — or the second accessor minted a third, which would be two caches"
);
assert!(
recorder.requests().len() == 1,
"building credentials sent a request to the VM: {:?}",
recorder.requests()
);
}
#[tokio::test]
async fn a_direct_session_offers_no_subprotocols_and_empty_headers() {
let recorder = Recorder::with([]);
let session = Session::builder("http://127.0.0.1:9000", "token")
.with_backend(recorder as http::SharedBackend)
.build_with(&crate::testing::TestAdapters::new())
.expect("builds");
assert!(
session
.connect_headers(9000)
.await
.expect("no proxy is not a failure")
.is_empty(),
"a direct session's requests carry no proxy headers, so neither should this"
);
assert!(
session
.connect_subprotocols(9000)
.await
.expect("no proxy is not a failure")
.is_none(),
"a handshake with the base subprotocol and no token is refused for a reason that \
names neither"
);
}
#[tokio::test]
async fn connect_credentials_re_mint_when_the_refresh_window_has_rolled_over() {
let recorder = Recorder::with([]);
let (session, auth, clock) = session_with(recorder);
let first = session
.connect_subprotocols(9000)
.await
.expect("mints")
.expect("proxied");
assert!(first[1].ends_with(&CountingMinter::value(0)));
clock.advance(DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
let second = session
.connect_subprotocols(9000)
.await
.expect("re-mints")
.expect("proxied");
assert_eq!(auth.mint_count(), 2);
assert!(
second[1].ends_with(&CountingMinter::value(1)),
"the handshake would carry a token past its refresh window: {}",
second[1]
);
}
#[tokio::test]
async fn procs_is_a_bearer_get_of_the_procs_route_with_a_typed_body() {
let recorder = Recorder::with([Reply::ok(serde_json::json!({
"procs": [{
"exec_id": "e1",
"pgid": 4242,
"started_at": 1_756_500_000u64,
"child_exited": true,
"reap": false,
"pids": [4243],
}]
}))]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let listed = session.procs().await.expect("procs");
let request = recorder.last();
assert_eq!(request.method, "GET");
assert_eq!(request.path, "/v1/procs");
assert_eq!(
header(&request, "authorization").as_deref(),
Some("Bearer agent-token-abcdef"),
"the procs route is bearer-guarded; without the token it is a 503 or a 401"
);
assert_eq!(listed.procs.len(), 1);
assert_eq!(listed.procs[0].exec_id, "e1");
assert_eq!(listed.procs[0].pgid, Some(4242));
assert!(listed.procs[0].child_exited);
assert_eq!(listed.procs[0].pids, [4243]);
}
#[tokio::test]
async fn a_start_request_carries_the_reap_flag_on_the_wire() {
let recorder = Recorder::with([Reply::ok(
serde_json::json!({"exec_id":"e1","phase":"running"}),
)]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let mut request = start_request("e1");
request.reap_group_on_exit = true;
session.run(request).await.expect("start");
let body: serde_json::Value =
serde_json::from_slice(&recorder.last().body).expect("a JSON start body");
assert_eq!(
body["reap_group_on_exit"],
serde_json::Value::Bool(true),
"the daemon defaults a missing flag to false, so a dropped key is a silent \
behaviour change: {body}"
);
}
#[test]
fn a_session_debug_does_not_print_the_agent_token() {
let recorder = Recorder::with([]);
let session = Session::builder("https://vm.example", "super-secret-token")
.with_backend(recorder as http::SharedBackend)
.build_with(&crate::testing::TestAdapters::new())
.expect("builds");
let rendered = format!("{session:?}");
assert!(rendered.contains("vm.example"), "{rendered}");
assert!(
!rendered.contains("super-secret"),
"the agent token reached a Debug string: {rendered}"
);
}
#[tokio::test(start_paused = true)]
async fn a_ready_wait_longer_than_the_clock_can_hold_still_polls() {
let recorder =
Recorder::with([Reply::ok(health_body(false)), Reply::ok(health_body(true))]);
let (session, _, _) = session_with(Arc::clone(&recorder));
let health = session
.wait_until_ready(Duration::MAX)
.await
.expect("a far deadline waits for the bootstrap");
assert!(health.bootstrapped);
assert_eq!(
recorder.requests().len(),
2,
"the wait polled past the first not-bootstrapped answer"
);
}
}
#[cfg(test)]
mod sse_fuzz;