pub mod exec;
pub mod files;
pub mod http;
pub mod proxy;
pub mod sse;
use std::sync::Arc;
use std::time::Duration;
pub use exec::{EndReason, ExecHandle, ExecResult, StreamEnd, StreamOptions, mint_exec_id};
pub use http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream, ReqwestBackend};
pub use proxy::{
Clock, DEFAULT_AGENT_PORT, DEFAULT_REFRESH_AFTER, MAX_TOKEN_LIFETIME, PROXY_AUTH_HEADER,
PROXY_PORT_HEADER, ProxyAuth, ProxyToken, TokenMinter, TokioClock, WS_AUTH_SUBPROTOCOL_PREFIX,
WS_PORT_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL,
};
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_READY_TIMEOUT: Duration = Duration::from_secs(120);
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, 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
}
pub struct Session {
transport: Arc<Transport>,
endpoint: String,
port: u16,
}
impl Session {
pub fn agent_token(&self) -> &str {
&self.transport.agent_token
}
pub async fn connect(
endpoint: impl Into<String>,
agent_token: impl Into<String>,
minter: Arc<dyn TokenMinter>,
) -> Result<Session, Error> {
Self::builder(endpoint, agent_token)
.with_minter(minter)
.build()
}
pub fn direct(
endpoint: impl Into<String>,
agent_token: impl Into<String>,
) -> Result<Session, Error> {
Self::builder(endpoint, agent_token).build()
}
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,
}
}
pub fn endpoint(&self) -> &str {
&self.endpoint
}
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) => proxy.headers_for_port(port).await,
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(proxy.subprotocols(port).await?)),
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 wait_until_ready(
&self,
timeout: Duration,
) -> Result<protocol::health::Health, Error> {
let deadline = tokio::time::Instant::now() + 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 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 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>>,
}
impl SessionBuilder {
#[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
}
pub fn build(self) -> Result<Session, Error> {
let backend = match self.backend {
Some(backend) => backend,
None => Arc::new(ReqwestBackend::new(&self.endpoint, self.timeout)?),
};
let proxy = match (self.proxy, self.minter) {
(Some(proxy), _) => Some(proxy),
(None, Some(minter)) => Some(Arc::new(ProxyAuth::new(minter, self.port))),
(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,
})
}
}
#[cfg(test)]
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(crate) enum Reply {
Body(u16, Vec<u8>),
Chunks(u16, Vec<Vec<u8>>),
Cut(&'static str),
}
impl Reply {
pub(crate) fn json(status: u16, body: impl serde::Serialize) -> Self {
Reply::Body(status, serde_json::to_vec(&body).expect("test body"))
}
pub(crate) fn ok(body: impl serde::Serialize) -> Self {
Reply::json(200, body)
}
}
#[derive(Default)]
pub(crate) struct Recorder {
seen: Mutex<Vec<HttpRequest>>,
replies: Mutex<std::collections::VecDeque<Reply>>,
}
impl Recorder {
pub(crate) 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(crate) fn requests(&self) -> Vec<HttpRequest> {
self.seen
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
pub(crate) 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()) })
}
}
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::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)),
};
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(crate) fn session_with(
recorder: Arc<Recorder>,
) -> (Session, Arc<ProxyAuth>, Arc<proxy::testing::ManualClock>) {
let clock = Arc::new(proxy::testing::ManualClock::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))
.build()
.expect("builds");
(session, auth, clock)
}
pub(crate) 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,
})
}
}
#[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 {
exec_id: exec_id.to_string(),
command: vec!["/bin/true".to_string()],
shell: false,
cwd: None,
env: Default::default(),
user: None,
group: None,
timeout_sec: None,
stdin: false,
}
}
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())
}
#[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]
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()
.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::ManualClock::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()
.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_READY_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_READY_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()
.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]
);
}
#[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()
.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}"
);
}
}