1pub mod exec;
33pub mod files;
34pub mod forward;
35pub mod http;
36pub mod proxy;
37pub mod shell;
38pub mod sse;
39pub mod tunnel;
40
41use std::sync::Arc;
42use std::time::Duration;
43
44pub use exec::{EndReason, ExecHandle, ExecResult, StreamEnd, StreamOptions, mint_exec_id};
45pub use forward::{
46 DEFAULT_EXCHANGE_TIMEOUT, ForwardClient, ForwardEvent, ForwardSpec, forwards_request_header,
47 refusal_explanation, upstream_url,
48};
49pub use http::{
50 ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream, ReqwestBackend, SharedBackend,
51};
52pub use proxy::{
53 Clock, DEFAULT_AGENT_PORT, DEFAULT_REFRESH_AFTER, MAX_TOKEN_LIFETIME, PROXY_AUTH_HEADER,
54 PROXY_PORT_HEADER, ProxyAuth, ProxyToken, TokenMinter, TokioClock, WS_AUTH_SUBPROTOCOL_PREFIX,
55 WS_PORT_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL,
56};
57pub use sse::{ExecEvent, Frame, SseParser};
58pub use tunnel::{TUNNEL_CHUNK_BYTES, TunnelEnd, explain_close, relay_connection, tunnel_url};
59
60use crate::error::{Error, ErrorKind, WireKind};
61
62pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
64
65pub const DEFAULT_READY_TIMEOUT: Duration = Duration::from_secs(120);
67
68const READY_POLL_INTERVAL: Duration = Duration::from_secs(2);
70
71pub struct Transport {
76 backend: http::SharedBackend,
77 agent_token: String,
78 proxy: Option<Arc<ProxyAuth>>,
83 timeout: Duration,
84}
85
86impl Transport {
87 pub fn proxy(&self) -> Option<&Arc<ProxyAuth>> {
89 self.proxy.as_ref()
90 }
91
92 async fn headers(&self, token: Option<Option<&str>>) -> Result<Vec<(String, String)>, Error> {
101 let mut headers = match &self.proxy {
104 Some(proxy) => proxy.headers().await?,
105 None => Vec::new(),
106 };
107 let bearer = match token {
108 Some(explicit) => explicit,
109 None => Some(self.agent_token.as_str()),
110 };
111 if let Some(bearer) = bearer {
112 headers.push(("Authorization".to_string(), format!("Bearer {bearer}")));
113 }
114 Ok(headers)
115 }
116
117 async fn request(&self, mut request: HttpRequest) -> Result<HttpResponse, Error> {
119 let mut headers = self.headers(request_token(&request)).await?;
128 request.headers.retain(|(name, _)| name != TOKEN_INTENT);
129 headers.append(&mut request.headers);
130 request.headers = headers;
131 if request.timeout.is_none() {
132 request.timeout = Some(self.timeout);
133 }
134 self.backend.send(request).await
135 }
136
137 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
139 let (method, path) = (request.method, request.path.clone());
140 let response = self.request(request).await?;
141 response.error_for_status(method, &path)?;
142 Ok(response)
143 }
144
145 async fn send_json<T: serde::de::DeserializeOwned>(
147 &self,
148 request: HttpRequest,
149 ) -> Result<T, Error> {
150 let (method, path) = (request.method, request.path.clone());
151 let response = self.send(request).await?;
152 serde_json::from_slice(&response.body).map_err(|err| {
153 Error::wire(
157 WireKind::ProtocolError,
158 format!(
159 "{method} {path} answered a body this client cannot read: {err}; \
160 check /v1/schema for the daemon's protocol version"
161 ),
162 )
163 .with_source(err)
164 })
165 }
166}
167
168const TOKEN_INTENT: &str = "x-microvms-core-token-intent";
175
176const TOKEN_INTENT_NONE: &str = "none";
178
179fn request_token(request: &HttpRequest) -> Option<Option<&str>> {
180 match request.header(TOKEN_INTENT) {
181 None => None,
182 Some(TOKEN_INTENT_NONE) => Some(None),
183 Some(explicit) => Some(Some(explicit)),
184 }
185}
186
187fn unauthenticated(mut request: HttpRequest) -> HttpRequest {
189 request
190 .headers
191 .push((TOKEN_INTENT.to_string(), TOKEN_INTENT_NONE.to_string()));
192 request
193}
194
195pub struct Session {
197 transport: Arc<Transport>,
198 endpoint: String,
199 port: u16,
200}
201
202impl Session {
203 pub fn agent_token(&self) -> &str {
211 &self.transport.agent_token
212 }
213
214 pub async fn connect(
220 endpoint: impl Into<String>,
221 agent_token: impl Into<String>,
222 minter: Arc<dyn TokenMinter>,
223 ) -> Result<Session, Error> {
224 Self::builder(endpoint, agent_token)
225 .with_minter(minter)
226 .build()
227 }
228
229 pub fn direct(
235 endpoint: impl Into<String>,
236 agent_token: impl Into<String>,
237 ) -> Result<Session, Error> {
238 Self::builder(endpoint, agent_token).build()
239 }
240
241 pub fn builder(endpoint: impl Into<String>, agent_token: impl Into<String>) -> SessionBuilder {
243 SessionBuilder {
244 endpoint: endpoint.into(),
245 agent_token: agent_token.into(),
246 minter: None,
247 backend: None,
248 port: DEFAULT_AGENT_PORT,
249 timeout: DEFAULT_REQUEST_TIMEOUT,
250 proxy: None,
251 }
252 }
253
254 pub fn endpoint(&self) -> &str {
256 &self.endpoint
257 }
258
259 pub fn port(&self) -> u16 {
261 self.port
262 }
263
264 pub fn proxy_auth(&self) -> Option<&Arc<ProxyAuth>> {
266 self.transport.proxy()
267 }
268
269 pub async fn connect_headers(&self, port: u16) -> Result<Vec<(String, String)>, Error> {
291 match self.transport.proxy() {
292 Some(proxy) => proxy.headers_for_port(port).await,
293 None => Ok(Vec::new()),
294 }
295 }
296
297 pub async fn connect_subprotocols(&self, port: u16) -> Result<Option<[String; 3]>, Error> {
309 match self.transport.proxy() {
310 Some(proxy) => Ok(Some(proxy.subprotocols(port).await?)),
311 None => Ok(None),
312 }
313 }
314
315 pub fn rebind(&mut self, endpoint: String) {
327 self.endpoint = endpoint;
328 if let Some(proxy) = self.transport.proxy() {
329 proxy.invalidate();
330 }
331 }
332
333 pub async fn health(&self) -> Result<protocol::health::Health, Error> {
342 self.transport
343 .send_json(unauthenticated(HttpRequest::new("GET", "/v1/health")))
344 .await
345 }
346
347 pub async fn wait_until_ready(
355 &self,
356 timeout: Duration,
357 ) -> Result<protocol::health::Health, Error> {
358 let deadline = tokio::time::Instant::now() + timeout;
359 let mut last: Option<Error> = None;
360 loop {
361 match self.health().await {
362 Ok(health) if health.bootstrapped => return Ok(health),
363 Ok(_) => {}
364 Err(err) if err.retryable() => last = Some(err),
365 Err(err) => return Err(err),
366 }
367 let now = tokio::time::Instant::now();
368 if now >= deadline {
369 let detail = match last {
370 Some(err) => format!(" (last error: {err})"),
371 None => String::new(),
372 };
373 return Err(Error::new(
374 ErrorKind::Timeout,
375 format!(
376 "the daemon was not bootstrapped within {}s{detail}",
377 timeout.as_secs()
378 ),
379 ));
380 }
381 tokio::time::sleep(READY_POLL_INTERVAL.min(deadline - now)).await;
382 }
383 }
384
385 pub async fn run(&self, req: protocol::exec::StartRequest) -> Result<ExecHandle, Error> {
393 let requested = req.exec_id.clone();
394 let mut request = HttpRequest::new("POST", "/v1/exec/start");
395 request
396 .headers
397 .push(("content-type".into(), "application/json".into()));
398 request.body = serde_json::to_vec(&req).map_err(|err| {
399 Error::invalid_arg(format!("the start request will not serialize: {err}"))
400 })?;
401 let started: protocol::exec::StartResponse = self.transport.send_json(request).await?;
402 let exec_id = if started.exec_id.is_empty() {
407 requested
408 } else {
409 started.exec_id
410 };
411 Ok(ExecHandle::new(Arc::clone(&self.transport), exec_id))
412 }
413
414 pub fn exec(&self, exec_id: impl Into<String>) -> ExecHandle {
416 ExecHandle::new(Arc::clone(&self.transport), exec_id.into())
417 }
418
419 pub async fn run_sync(
421 &self,
422 req: protocol::exec::StartRequest,
423 timeout: Duration,
424 ) -> Result<ExecResult, Error> {
425 self.run(req).await?.wait_and_ack(timeout).await
426 }
427
428 pub async fn kill(&self, exec_id: &str) -> Result<bool, Error> {
430 self.exec(exec_id).kill().await
431 }
432
433 pub async fn upload_file(
437 &self,
438 path: &str,
439 data: &[u8],
440 mode: Option<&str>,
441 ) -> Result<(), Error> {
442 files::upload_file(&self.transport, path, data, mode).await
443 }
444
445 pub async fn download_file(&self, path: &str) -> Result<Vec<u8>, Error> {
447 files::download_file(&self.transport, path).await
448 }
449
450 pub async fn file_exists(&self, path: &str) -> Result<bool, Error> {
452 files::file_exists(&self.transport, path).await
453 }
454
455 pub async fn upload_tar(&self, remote: &str, archive: &[u8]) -> Result<(), Error> {
457 files::upload_tar(&self.transport, remote, archive).await
458 }
459
460 pub async fn download_tar(&self, remote: &str) -> Result<Vec<u8>, Error> {
462 files::download_tar(&self.transport, remote).await
463 }
464}
465
466impl std::fmt::Debug for Session {
467 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468 f.debug_struct("Session")
470 .field("endpoint", &self.endpoint)
471 .field("port", &self.port)
472 .field("proxy", &self.transport.proxy())
473 .finish()
474 }
475}
476
477pub struct SessionBuilder {
479 endpoint: String,
480 agent_token: String,
481 minter: Option<Arc<dyn TokenMinter>>,
482 backend: Option<http::SharedBackend>,
483 port: u16,
484 timeout: Duration,
485 proxy: Option<Arc<ProxyAuth>>,
486}
487
488impl SessionBuilder {
489 #[must_use]
491 pub fn with_minter(mut self, minter: Arc<dyn TokenMinter>) -> Self {
492 self.minter = Some(minter);
493 self
494 }
495
496 #[must_use]
499 pub fn with_proxy_auth(mut self, proxy: Arc<ProxyAuth>) -> Self {
500 self.proxy = Some(proxy);
501 self
502 }
503
504 #[must_use]
506 pub fn with_backend(mut self, backend: http::SharedBackend) -> Self {
507 self.backend = Some(backend);
508 self
509 }
510
511 #[must_use]
513 pub fn with_port(mut self, port: u16) -> Self {
514 self.port = port;
515 self
516 }
517
518 #[must_use]
520 pub fn with_timeout(mut self, timeout: Duration) -> Self {
521 self.timeout = timeout;
522 self
523 }
524
525 pub fn build(self) -> Result<Session, Error> {
527 let backend = match self.backend {
528 Some(backend) => backend,
529 None => Arc::new(ReqwestBackend::new(&self.endpoint, self.timeout)?),
530 };
531 let proxy = match (self.proxy, self.minter) {
532 (Some(proxy), _) => Some(proxy),
533 (None, Some(minter)) => Some(Arc::new(ProxyAuth::new(minter, self.port))),
534 (None, None) => None,
535 };
536 Ok(Session {
537 transport: Arc::new(Transport {
538 backend,
539 agent_token: self.agent_token,
540 proxy,
541 timeout: self.timeout,
542 }),
543 endpoint: self.endpoint,
544 port: self.port,
545 })
546 }
547}
548
549#[cfg(test)]
550pub(crate) mod testing {
551 use std::collections::HashMap;
559 use std::sync::{Mutex, PoisonError};
560
561 use futures_util::future::BoxFuture;
562
563 use super::http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream};
564 use super::*;
565
566 pub(crate) enum Reply {
568 Body(u16, Vec<u8>),
570 Chunks(u16, Vec<Vec<u8>>),
572 Cut(&'static str),
574 }
575
576 impl Reply {
577 pub(crate) fn json(status: u16, body: impl serde::Serialize) -> Self {
578 Reply::Body(status, serde_json::to_vec(&body).expect("test body"))
579 }
580
581 pub(crate) fn ok(body: impl serde::Serialize) -> Self {
582 Reply::json(200, body)
583 }
584 }
585
586 #[derive(Default)]
587 pub(crate) struct Recorder {
588 seen: Mutex<Vec<HttpRequest>>,
589 replies: Mutex<std::collections::VecDeque<Reply>>,
590 }
591
592 impl Recorder {
593 pub(crate) fn with(replies: impl IntoIterator<Item = Reply>) -> Arc<Self> {
594 Arc::new(Self {
595 seen: Mutex::new(Vec::new()),
596 replies: Mutex::new(replies.into_iter().collect()),
597 })
598 }
599
600 pub(crate) fn requests(&self) -> Vec<HttpRequest> {
602 self.seen
603 .lock()
604 .unwrap_or_else(PoisonError::into_inner)
605 .clone()
606 }
607
608 pub(crate) fn last(&self) -> HttpRequest {
609 self.requests()
610 .pop()
611 .expect("the recorder saw no request at all")
612 }
613
614 fn record(&self, request: HttpRequest) -> Reply {
615 self.seen
616 .lock()
617 .unwrap_or_else(PoisonError::into_inner)
618 .push(request.clone());
619 self.replies
620 .lock()
621 .unwrap_or_else(PoisonError::into_inner)
622 .pop_front()
623 .unwrap_or_else(|| {
624 panic!(
625 "the recorder ran out of replies at {} {}",
626 request.method, request.path
627 )
628 })
629 }
630 }
631
632 struct Queued(std::collections::VecDeque<Vec<u8>>);
633
634 impl ChunkSource for Queued {
635 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
636 Box::pin(async move { Ok(self.0.pop_front()) })
637 }
638 }
639
640 impl HttpBackend for Recorder {
641 fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
642 let reply = self.record(request);
643 Box::pin(async move {
644 match reply {
645 Reply::Body(status, body) => Ok(HttpResponse {
646 status,
647 headers: HashMap::new(),
648 body,
649 }),
650 Reply::Chunks(status, chunks) => Ok(HttpResponse {
651 status,
652 headers: HashMap::new(),
653 body: chunks.concat(),
654 }),
655 Reply::Cut(why) => Err(Error::wire(WireKind::Transport, why)),
656 }
657 })
658 }
659
660 fn open_stream(
661 &self,
662 request: HttpRequest,
663 _idle_timeout: Duration,
664 ) -> BoxFuture<'_, Result<OpenStream, Error>> {
665 let reply = self.record(request);
666 Box::pin(async move {
667 let (status, chunks) = match reply {
668 Reply::Chunks(status, chunks) => (status, chunks),
669 Reply::Body(status, body) => (status, vec![body]),
670 Reply::Cut(why) => return Err(Error::wire(WireKind::Transport, why)),
671 };
672 let head = HttpResponse {
673 status,
674 headers: HashMap::new(),
675 body: if (200..300).contains(&status) {
678 Vec::new()
679 } else {
680 chunks.concat()
681 },
682 };
683 let source: Box<dyn ChunkSource> = if (200..300).contains(&status) {
684 Box::new(Queued(chunks.into_iter().collect()))
685 } else {
686 Box::new(Queued(std::collections::VecDeque::new()))
687 };
688 Ok((head, source))
689 })
690 }
691 }
692
693 pub(crate) fn session_with(
695 recorder: Arc<Recorder>,
696 ) -> (Session, Arc<ProxyAuth>, Arc<proxy::testing::ManualClock>) {
697 let clock = Arc::new(proxy::testing::ManualClock::default());
698 let auth = Arc::new(
699 ProxyAuth::with_refresh_after(
700 Arc::new(proxy::testing::CountingMinter::default()),
701 DEFAULT_AGENT_PORT,
702 DEFAULT_REFRESH_AFTER,
703 Arc::clone(&clock) as Arc<dyn Clock>,
704 )
705 .expect("the default interval is below the ceiling"),
706 );
707 let session = Session::builder("https://vm.example", "agent-token-abcdef")
708 .with_backend(recorder)
709 .with_proxy_auth(Arc::clone(&auth))
710 .build()
711 .expect("builds");
712 (session, auth, clock)
713 }
714
715 pub(crate) fn health_body(bootstrapped: bool) -> serde_json::Value {
716 serde_json::json!({
717 "version": "0.1.0",
718 "bootstrapped": bootstrapped,
719 "disk": null,
720 "identity_degraded": false,
721 "identity_repaired": true,
722 })
723 }
724}
725
726#[cfg(test)]
727mod tests {
728 use super::testing::{Recorder, Reply, health_body, session_with};
729 use super::*;
730 use crate::session::proxy::testing::CountingMinter;
731
732 fn start_request(exec_id: &str) -> protocol::exec::StartRequest {
733 protocol::exec::StartRequest {
734 exec_id: exec_id.to_string(),
735 command: vec!["/bin/true".to_string()],
736 shell: false,
737 cwd: None,
738 env: Default::default(),
739 user: None,
740 group: None,
741 timeout_sec: None,
742 stdin: false,
743 }
744 }
745
746 fn header(request: &HttpRequest, name: &str) -> Option<String> {
747 request.header(name).map(str::to_string)
748 }
749
750 fn header_of(headers: &[(String, String)], name: &str) -> Option<String> {
752 headers
753 .iter()
754 .find(|(key, _)| key.eq_ignore_ascii_case(name))
755 .map(|(_, value)| value.clone())
756 }
757
758 #[tokio::test]
766 async fn a_callers_content_type_survives_the_auth_header_injection() {
767 let recorder = Recorder::with([Reply::ok(
768 serde_json::json!({"exec_id":"e1","phase":"running"}),
769 )]);
770 let (session, _, _) = session_with(Arc::clone(&recorder));
771
772 session.run(start_request("e1")).await.expect("start");
773
774 let seen = recorder.requests();
775 assert_eq!(
776 header(&seen[0], "content-type").as_deref(),
777 Some("application/json"),
778 "the exec start lost its content type to the auth header injection"
779 );
780 }
781
782 #[tokio::test]
787 async fn the_session_hands_back_the_token_it_authenticates_with() {
788 let recorder = Recorder::with([]);
789 let (session, _, _) = session_with(Arc::clone(&recorder));
790 assert_eq!(session.agent_token(), "agent-token-abcdef");
791 }
792
793 #[tokio::test]
800 async fn every_endpoint_request_carries_both_proxy_headers() {
801 let recorder = Recorder::with([
802 Reply::ok(health_body(true)),
803 Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
804 ]);
805 let (session, _, _) = session_with(Arc::clone(&recorder));
806
807 session.health().await.expect("health succeeds");
808 session.run(start_request("e1")).await.expect("start");
809
810 let seen = recorder.requests();
811 assert_eq!(seen.len(), 2);
812 for request in &seen {
813 assert_eq!(
814 header(request, PROXY_AUTH_HEADER).as_deref(),
815 Some(CountingMinter::value(0).as_str()),
816 "{} {} went out without the proxy auth header",
817 request.method,
818 request.path
819 );
820 assert_eq!(
821 header(request, PROXY_PORT_HEADER).as_deref(),
822 Some("9000"),
823 "{} {} went out without the proxy port header, which the proxy \
824 rejects as if the token were bad",
825 request.method,
826 request.path
827 );
828 }
829 }
830
831 #[tokio::test]
837 async fn health_sends_no_bearer_and_an_exec_route_does() {
838 let recorder = Recorder::with([
839 Reply::ok(health_body(true)),
840 Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
841 ]);
842 let (session, _, _) = session_with(Arc::clone(&recorder));
843
844 session.health().await.expect("health");
845 let seen = recorder.requests();
846 assert_eq!(header(&seen[0], "authorization"), None);
847 assert_eq!(
848 header(&seen[0], "x-microvms-core-token-intent"),
849 None,
850 "the internal token-intent marker must not reach the wire"
851 );
852
853 session.run(start_request("e1")).await.expect("start");
854 assert_eq!(
855 header(&recorder.last(), "authorization").as_deref(),
856 Some("Bearer agent-token-abcdef")
857 );
858 }
859
860 #[tokio::test]
863 async fn a_direct_session_sends_no_proxy_headers() {
864 let recorder = Recorder::with([Reply::ok(health_body(true))]);
865 let session = Session::builder("http://127.0.0.1:9000", "token")
866 .with_backend(Arc::clone(&recorder) as http::SharedBackend)
867 .build()
868 .expect("builds");
869 assert!(session.proxy_auth().is_none());
870
871 session.health().await.expect("health");
872 let request = recorder.last();
873 assert_eq!(header(&request, PROXY_AUTH_HEADER), None);
874 assert_eq!(header(&request, PROXY_PORT_HEADER), None);
875 }
876
877 #[tokio::test]
880 async fn a_mint_failure_surfaces_as_a_retryable_request_error() {
881 let recorder = Recorder::with([Reply::ok(health_body(true))]);
882 let auth = Arc::new(
883 ProxyAuth::with_refresh_after(
884 Arc::new(CountingMinter::failing(1)),
885 DEFAULT_AGENT_PORT,
886 DEFAULT_REFRESH_AFTER,
887 Arc::new(proxy::testing::ManualClock::default()),
888 )
889 .expect("interval accepted"),
890 );
891 let session = Session::builder("https://vm.example", "token")
892 .with_backend(Arc::clone(&recorder) as http::SharedBackend)
893 .with_proxy_auth(Arc::clone(&auth))
894 .build()
895 .expect("builds");
896
897 let err = session.health().await.expect_err("the mint fails");
898 assert_eq!(err.kind(), ErrorKind::Retryable);
899 assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
900 assert!(
901 recorder.requests().is_empty(),
902 "a request went out without a token"
903 );
904
905 session.health().await.expect("the retry succeeds");
906 assert_eq!(auth.mint_count(), 1);
907 }
908
909 #[tokio::test]
912 async fn a_daemon_status_arrives_as_its_typed_error() {
913 for (status, expected) in [
914 (401, WireKind::Unauthorized),
915 (404, WireKind::NotFound),
916 (409, WireKind::Conflict),
917 (503, WireKind::NotBootstrapped),
918 ] {
919 let recorder = Recorder::with([Reply::Body(status, b"{\"error\":\"x\"}".to_vec())]);
920 let (session, _, _) = session_with(recorder);
921 let err = session.health().await.expect_err("a failing status");
922 assert_eq!(err.wire_kind(), Some(expected), "status {status}");
923 }
924 }
925
926 #[tokio::test(start_paused = true)]
929 async fn wait_until_ready_polls_through_not_bootstrapped_and_a_dropped_connection() {
930 let recorder = Recorder::with([
931 Reply::Cut("connection refused"),
932 Reply::Body(503, b"not bootstrapped".to_vec()),
933 Reply::ok(health_body(false)),
934 Reply::ok(health_body(true)),
935 ]);
936 let (session, _, _) = session_with(Arc::clone(&recorder));
937
938 let health = session
939 .wait_until_ready(DEFAULT_READY_TIMEOUT)
940 .await
941 .expect("the daemon comes up");
942 assert!(health.bootstrapped);
943 assert_eq!(recorder.requests().len(), 4);
944 }
945
946 #[tokio::test(start_paused = true)]
949 async fn wait_until_ready_gives_up_at_once_on_a_credential_failure() {
950 let recorder = Recorder::with([Reply::Body(401, b"wrong token".to_vec())]);
951 let (session, _, _) = session_with(Arc::clone(&recorder));
952
953 let err = session
954 .wait_until_ready(DEFAULT_READY_TIMEOUT)
955 .await
956 .expect_err("a 401 is fatal");
957 assert_eq!(err.kind(), ErrorKind::Credentials);
958 assert_eq!(
959 recorder.requests().len(),
960 1,
961 "a fatal error was retried, so a wrong token costs the full timeout"
962 );
963 }
964
965 #[tokio::test(start_paused = true)]
968 async fn wait_until_ready_times_out_naming_the_last_retryable_error() {
969 let recorder = Recorder::with([
970 Reply::Cut("connection refused"),
971 Reply::Cut("connection refused"),
972 Reply::Cut("connection refused"),
973 ]);
974 let (session, _, _) = session_with(recorder);
975
976 let err = session
977 .wait_until_ready(Duration::from_secs(4))
978 .await
979 .expect_err("the daemon never comes up");
980 assert_eq!(err.kind(), ErrorKind::Timeout);
981 assert!(
982 err.to_string().contains("connection refused"),
983 "the timeout must name what it kept hitting: {err}"
984 );
985 }
986
987 #[tokio::test]
993 async fn rebind_invalidates_the_proxy_token_so_the_next_request_mints() {
994 let recorder = Recorder::with([Reply::ok(health_body(true)), Reply::ok(health_body(true))]);
995 let (mut session, auth, _) = session_with(Arc::clone(&recorder));
996
997 session.health().await.expect("health");
998 assert_eq!(auth.mint_count(), 1);
999
1000 session.rebind("https://vm.example".to_string());
1001 assert!(!auth.is_cached(), "rebind did not drop the token");
1002
1003 session.health().await.expect("health");
1004 assert_eq!(auth.mint_count(), 2);
1005 assert_eq!(
1006 header(&recorder.last(), PROXY_AUTH_HEADER).as_deref(),
1007 Some(CountingMinter::value(1).as_str()),
1008 "the request after a rebind carried the pre-suspend token"
1009 );
1010 }
1011
1012 #[tokio::test]
1015 async fn a_start_request_serializes_through_the_protocol_crate() {
1016 let recorder = Recorder::with([Reply::ok(
1017 serde_json::json!({"exec_id":"e-confirmed","phase":"running"}),
1018 )]);
1019 let (session, _, _) = session_with(Arc::clone(&recorder));
1020
1021 let handle = session.run(start_request("e-asked")).await.expect("start");
1022 assert_eq!(
1023 handle.exec_id(),
1024 "e-confirmed",
1025 "the handle addresses the id the client asked for rather than the one the \
1026 daemon confirmed, so every later call would 404 if they diverged"
1027 );
1028
1029 let request = recorder.last();
1030 assert_eq!(request.method, "POST");
1031 assert_eq!(request.path, "/v1/exec/start");
1032 let body: serde_json::Value = serde_json::from_slice(&request.body).expect("json body");
1033 assert_eq!(body["exec_id"], "e-asked");
1034 assert_eq!(body["command"], serde_json::json!(["/bin/true"]));
1035 }
1036
1037 #[tokio::test]
1040 async fn an_unreadable_response_body_names_the_schema_route() {
1041 let recorder = Recorder::with([Reply::Body(200, b"{\"unexpected\":true}".to_vec())]);
1042 let (session, _, _) = session_with(recorder);
1043 let err = session.health().await.expect_err("the body will not parse");
1044 assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
1045 assert!(err.to_string().contains("/v1/schema"), "{err}");
1046 }
1047
1048 #[tokio::test]
1069 async fn a_session_answers_connect_credentials_for_a_port_off_its_own_token_cache() {
1070 let recorder = Recorder::with([Reply::ok(health_body(true))]);
1071 let (session, auth, _) = session_with(Arc::clone(&recorder));
1072
1073 session.health().await.expect("health warms the cache");
1074 assert_eq!(auth.mint_count(), 1);
1075
1076 let headers = session
1077 .connect_headers(8080)
1078 .await
1079 .expect("the cached token answers");
1080 let offered = session
1081 .connect_subprotocols(8080)
1082 .await
1083 .expect("the cached token answers")
1084 .expect("a proxied session offers subprotocols");
1085
1086 assert_eq!(
1087 header_of(&headers, PROXY_PORT_HEADER).as_deref(),
1088 Some("8080"),
1089 "the caller asked for 8080 and would have targeted the session's port"
1090 );
1091 assert_eq!(
1093 header_of(&headers, PROXY_AUTH_HEADER).as_deref(),
1094 Some(CountingMinter::value(1).as_str()),
1095 "the header pair carried the agent-port-only token, which the proxy refuses for \
1096 8080 with 403 'Access to port denied'"
1097 );
1098 assert_eq!(offered[0], proxy::WS_SUBPROTOCOL);
1099 assert_eq!(
1100 offered[1],
1101 format!(
1102 "{}{}",
1103 proxy::WS_AUTH_SUBPROTOCOL_PREFIX,
1104 CountingMinter::value(1)
1105 ),
1106 "the handshake carried a different token than the header path, so they are two \
1107 caches rather than one"
1108 );
1109 assert_eq!(offered[2], "lambda-microvms.port.8080");
1110 assert_eq!(
1111 auth.mint_count(),
1112 2,
1113 "one mint covered both the agent port and 8080, which the control plane does not \
1114 do — or the second accessor minted a third, which would be two caches"
1115 );
1116 assert!(
1117 recorder.requests().len() == 1,
1118 "building credentials sent a request to the VM: {:?}",
1119 recorder.requests()
1120 );
1121 }
1122
1123 #[tokio::test]
1134 async fn a_direct_session_offers_no_subprotocols_and_empty_headers() {
1135 let recorder = Recorder::with([]);
1136 let session = Session::builder("http://127.0.0.1:9000", "token")
1137 .with_backend(recorder as http::SharedBackend)
1138 .build()
1139 .expect("builds");
1140
1141 assert!(
1142 session
1143 .connect_headers(9000)
1144 .await
1145 .expect("no proxy is not a failure")
1146 .is_empty(),
1147 "a direct session's requests carry no proxy headers, so neither should this"
1148 );
1149 assert!(
1150 session
1151 .connect_subprotocols(9000)
1152 .await
1153 .expect("no proxy is not a failure")
1154 .is_none(),
1155 "a handshake with the base subprotocol and no token is refused for a reason that \
1156 names neither"
1157 );
1158 }
1159
1160 #[tokio::test]
1171 async fn connect_credentials_re_mint_when_the_refresh_window_has_rolled_over() {
1172 let recorder = Recorder::with([]);
1173 let (session, auth, clock) = session_with(recorder);
1174
1175 let first = session
1176 .connect_subprotocols(9000)
1177 .await
1178 .expect("mints")
1179 .expect("proxied");
1180 assert!(first[1].ends_with(&CountingMinter::value(0)));
1181
1182 clock.advance(DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
1183 let second = session
1184 .connect_subprotocols(9000)
1185 .await
1186 .expect("re-mints")
1187 .expect("proxied");
1188
1189 assert_eq!(auth.mint_count(), 2);
1190 assert!(
1191 second[1].ends_with(&CountingMinter::value(1)),
1192 "the handshake would carry a token past its refresh window: {}",
1193 second[1]
1194 );
1195 }
1196
1197 #[test]
1199 fn a_session_debug_does_not_print_the_agent_token() {
1200 let recorder = Recorder::with([]);
1201 let session = Session::builder("https://vm.example", "super-secret-token")
1202 .with_backend(recorder as http::SharedBackend)
1203 .build()
1204 .expect("builds");
1205 let rendered = format!("{session:?}");
1206 assert!(rendered.contains("vm.example"), "{rendered}");
1207 assert!(
1208 !rendered.contains("super-secret"),
1209 "the agent token reached a Debug string: {rendered}"
1210 );
1211 }
1212}