1pub mod exec;
31pub mod files;
32pub mod http;
33pub mod proxy;
34pub mod sse;
35
36use std::sync::Arc;
37use std::time::Duration;
38
39pub use exec::{EndReason, ExecHandle, ExecResult, StreamEnd, StreamOptions, mint_exec_id};
40pub use http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream, ReqwestBackend};
41pub use proxy::{
42 Clock, DEFAULT_AGENT_PORT, DEFAULT_REFRESH_AFTER, MAX_TOKEN_LIFETIME, PROXY_AUTH_HEADER,
43 PROXY_PORT_HEADER, ProxyAuth, ProxyToken, TokenMinter, TokioClock, WS_AUTH_SUBPROTOCOL_PREFIX,
44 WS_PORT_SUBPROTOCOL_PREFIX, WS_SUBPROTOCOL,
45};
46pub use sse::{ExecEvent, Frame, SseParser};
47
48use crate::error::{Error, ErrorKind, WireKind};
49
50pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
52
53pub const DEFAULT_READY_TIMEOUT: Duration = Duration::from_secs(120);
55
56const READY_POLL_INTERVAL: Duration = Duration::from_secs(2);
58
59pub struct Transport {
64 backend: http::SharedBackend,
65 agent_token: String,
66 proxy: Option<Arc<ProxyAuth>>,
71 timeout: Duration,
72}
73
74impl Transport {
75 pub fn proxy(&self) -> Option<&Arc<ProxyAuth>> {
77 self.proxy.as_ref()
78 }
79
80 async fn headers(&self, token: Option<Option<&str>>) -> Result<Vec<(String, String)>, Error> {
89 let mut headers = match &self.proxy {
92 Some(proxy) => proxy.headers().await?,
93 None => Vec::new(),
94 };
95 let bearer = match token {
96 Some(explicit) => explicit,
97 None => Some(self.agent_token.as_str()),
98 };
99 if let Some(bearer) = bearer {
100 headers.push(("Authorization".to_string(), format!("Bearer {bearer}")));
101 }
102 Ok(headers)
103 }
104
105 async fn request(&self, mut request: HttpRequest) -> Result<HttpResponse, Error> {
107 let mut headers = self.headers(request_token(&request)).await?;
116 request.headers.retain(|(name, _)| name != TOKEN_INTENT);
117 headers.append(&mut request.headers);
118 request.headers = headers;
119 if request.timeout.is_none() {
120 request.timeout = Some(self.timeout);
121 }
122 self.backend.send(request).await
123 }
124
125 async fn send(&self, request: HttpRequest) -> Result<HttpResponse, Error> {
127 let (method, path) = (request.method, request.path.clone());
128 let response = self.request(request).await?;
129 response.error_for_status(method, &path)?;
130 Ok(response)
131 }
132
133 async fn send_json<T: serde::de::DeserializeOwned>(
135 &self,
136 request: HttpRequest,
137 ) -> Result<T, Error> {
138 let (method, path) = (request.method, request.path.clone());
139 let response = self.send(request).await?;
140 serde_json::from_slice(&response.body).map_err(|err| {
141 Error::wire(
145 WireKind::ProtocolError,
146 format!(
147 "{method} {path} answered a body this client cannot read: {err}; \
148 check /v1/schema for the daemon's protocol version"
149 ),
150 )
151 .with_source(err)
152 })
153 }
154}
155
156const TOKEN_INTENT: &str = "x-microvms-core-token-intent";
163
164const TOKEN_INTENT_NONE: &str = "none";
166
167fn request_token(request: &HttpRequest) -> Option<Option<&str>> {
168 match request.header(TOKEN_INTENT) {
169 None => None,
170 Some(TOKEN_INTENT_NONE) => Some(None),
171 Some(explicit) => Some(Some(explicit)),
172 }
173}
174
175fn unauthenticated(mut request: HttpRequest) -> HttpRequest {
177 request
178 .headers
179 .push((TOKEN_INTENT.to_string(), TOKEN_INTENT_NONE.to_string()));
180 request
181}
182
183pub struct Session {
185 transport: Arc<Transport>,
186 endpoint: String,
187 port: u16,
188}
189
190impl Session {
191 pub fn agent_token(&self) -> &str {
199 &self.transport.agent_token
200 }
201
202 pub async fn connect(
208 endpoint: impl Into<String>,
209 agent_token: impl Into<String>,
210 minter: Arc<dyn TokenMinter>,
211 ) -> Result<Session, Error> {
212 Self::builder(endpoint, agent_token)
213 .with_minter(minter)
214 .build()
215 }
216
217 pub fn direct(
223 endpoint: impl Into<String>,
224 agent_token: impl Into<String>,
225 ) -> Result<Session, Error> {
226 Self::builder(endpoint, agent_token).build()
227 }
228
229 pub fn builder(endpoint: impl Into<String>, agent_token: impl Into<String>) -> SessionBuilder {
231 SessionBuilder {
232 endpoint: endpoint.into(),
233 agent_token: agent_token.into(),
234 minter: None,
235 backend: None,
236 port: DEFAULT_AGENT_PORT,
237 timeout: DEFAULT_REQUEST_TIMEOUT,
238 proxy: None,
239 }
240 }
241
242 pub fn endpoint(&self) -> &str {
244 &self.endpoint
245 }
246
247 pub fn port(&self) -> u16 {
249 self.port
250 }
251
252 pub fn proxy_auth(&self) -> Option<&Arc<ProxyAuth>> {
254 self.transport.proxy()
255 }
256
257 pub async fn connect_headers(&self, port: u16) -> Result<Vec<(String, String)>, Error> {
279 match self.transport.proxy() {
280 Some(proxy) => proxy.headers_for_port(port).await,
281 None => Ok(Vec::new()),
282 }
283 }
284
285 pub async fn connect_subprotocols(&self, port: u16) -> Result<Option<[String; 3]>, Error> {
297 match self.transport.proxy() {
298 Some(proxy) => Ok(Some(proxy.subprotocols(port).await?)),
299 None => Ok(None),
300 }
301 }
302
303 pub fn rebind(&mut self, endpoint: String) {
315 self.endpoint = endpoint;
316 if let Some(proxy) = self.transport.proxy() {
317 proxy.invalidate();
318 }
319 }
320
321 pub async fn health(&self) -> Result<protocol::health::Health, Error> {
330 self.transport
331 .send_json(unauthenticated(HttpRequest::new("GET", "/v1/health")))
332 .await
333 }
334
335 pub async fn wait_until_ready(
343 &self,
344 timeout: Duration,
345 ) -> Result<protocol::health::Health, Error> {
346 let deadline = tokio::time::Instant::now() + timeout;
347 let mut last: Option<Error> = None;
348 loop {
349 match self.health().await {
350 Ok(health) if health.bootstrapped => return Ok(health),
351 Ok(_) => {}
352 Err(err) if err.retryable() => last = Some(err),
353 Err(err) => return Err(err),
354 }
355 let now = tokio::time::Instant::now();
356 if now >= deadline {
357 let detail = match last {
358 Some(err) => format!(" (last error: {err})"),
359 None => String::new(),
360 };
361 return Err(Error::new(
362 ErrorKind::Timeout,
363 format!(
364 "the daemon was not bootstrapped within {}s{detail}",
365 timeout.as_secs()
366 ),
367 ));
368 }
369 tokio::time::sleep(READY_POLL_INTERVAL.min(deadline - now)).await;
370 }
371 }
372
373 pub async fn run(&self, req: protocol::exec::StartRequest) -> Result<ExecHandle, Error> {
381 let requested = req.exec_id.clone();
382 let mut request = HttpRequest::new("POST", "/v1/exec/start");
383 request
384 .headers
385 .push(("content-type".into(), "application/json".into()));
386 request.body = serde_json::to_vec(&req).map_err(|err| {
387 Error::invalid_arg(format!("the start request will not serialize: {err}"))
388 })?;
389 let started: protocol::exec::StartResponse = self.transport.send_json(request).await?;
390 let exec_id = if started.exec_id.is_empty() {
395 requested
396 } else {
397 started.exec_id
398 };
399 Ok(ExecHandle::new(Arc::clone(&self.transport), exec_id))
400 }
401
402 pub fn exec(&self, exec_id: impl Into<String>) -> ExecHandle {
404 ExecHandle::new(Arc::clone(&self.transport), exec_id.into())
405 }
406
407 pub async fn run_sync(
409 &self,
410 req: protocol::exec::StartRequest,
411 timeout: Duration,
412 ) -> Result<ExecResult, Error> {
413 self.run(req).await?.wait_and_ack(timeout).await
414 }
415
416 pub async fn kill(&self, exec_id: &str) -> Result<bool, Error> {
418 self.exec(exec_id).kill().await
419 }
420
421 pub async fn upload_file(
425 &self,
426 path: &str,
427 data: &[u8],
428 mode: Option<&str>,
429 ) -> Result<(), Error> {
430 files::upload_file(&self.transport, path, data, mode).await
431 }
432
433 pub async fn download_file(&self, path: &str) -> Result<Vec<u8>, Error> {
435 files::download_file(&self.transport, path).await
436 }
437
438 pub async fn file_exists(&self, path: &str) -> Result<bool, Error> {
440 files::file_exists(&self.transport, path).await
441 }
442
443 pub async fn upload_tar(&self, remote: &str, archive: &[u8]) -> Result<(), Error> {
445 files::upload_tar(&self.transport, remote, archive).await
446 }
447
448 pub async fn download_tar(&self, remote: &str) -> Result<Vec<u8>, Error> {
450 files::download_tar(&self.transport, remote).await
451 }
452}
453
454impl std::fmt::Debug for Session {
455 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456 f.debug_struct("Session")
458 .field("endpoint", &self.endpoint)
459 .field("port", &self.port)
460 .field("proxy", &self.transport.proxy())
461 .finish()
462 }
463}
464
465pub struct SessionBuilder {
467 endpoint: String,
468 agent_token: String,
469 minter: Option<Arc<dyn TokenMinter>>,
470 backend: Option<http::SharedBackend>,
471 port: u16,
472 timeout: Duration,
473 proxy: Option<Arc<ProxyAuth>>,
474}
475
476impl SessionBuilder {
477 #[must_use]
479 pub fn with_minter(mut self, minter: Arc<dyn TokenMinter>) -> Self {
480 self.minter = Some(minter);
481 self
482 }
483
484 #[must_use]
487 pub fn with_proxy_auth(mut self, proxy: Arc<ProxyAuth>) -> Self {
488 self.proxy = Some(proxy);
489 self
490 }
491
492 #[must_use]
494 pub fn with_backend(mut self, backend: http::SharedBackend) -> Self {
495 self.backend = Some(backend);
496 self
497 }
498
499 #[must_use]
501 pub fn with_port(mut self, port: u16) -> Self {
502 self.port = port;
503 self
504 }
505
506 #[must_use]
508 pub fn with_timeout(mut self, timeout: Duration) -> Self {
509 self.timeout = timeout;
510 self
511 }
512
513 pub fn build(self) -> Result<Session, Error> {
515 let backend = match self.backend {
516 Some(backend) => backend,
517 None => Arc::new(ReqwestBackend::new(&self.endpoint, self.timeout)?),
518 };
519 let proxy = match (self.proxy, self.minter) {
520 (Some(proxy), _) => Some(proxy),
521 (None, Some(minter)) => Some(Arc::new(ProxyAuth::new(minter, self.port))),
522 (None, None) => None,
523 };
524 Ok(Session {
525 transport: Arc::new(Transport {
526 backend,
527 agent_token: self.agent_token,
528 proxy,
529 timeout: self.timeout,
530 }),
531 endpoint: self.endpoint,
532 port: self.port,
533 })
534 }
535}
536
537#[cfg(test)]
538pub(crate) mod testing {
539 use std::collections::HashMap;
547 use std::sync::{Mutex, PoisonError};
548
549 use futures_util::future::BoxFuture;
550
551 use super::http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream};
552 use super::*;
553
554 pub(crate) enum Reply {
556 Body(u16, Vec<u8>),
558 Chunks(u16, Vec<Vec<u8>>),
560 Cut(&'static str),
562 }
563
564 impl Reply {
565 pub(crate) fn json(status: u16, body: impl serde::Serialize) -> Self {
566 Reply::Body(status, serde_json::to_vec(&body).expect("test body"))
567 }
568
569 pub(crate) fn ok(body: impl serde::Serialize) -> Self {
570 Reply::json(200, body)
571 }
572 }
573
574 #[derive(Default)]
575 pub(crate) struct Recorder {
576 seen: Mutex<Vec<HttpRequest>>,
577 replies: Mutex<std::collections::VecDeque<Reply>>,
578 }
579
580 impl Recorder {
581 pub(crate) fn with(replies: impl IntoIterator<Item = Reply>) -> Arc<Self> {
582 Arc::new(Self {
583 seen: Mutex::new(Vec::new()),
584 replies: Mutex::new(replies.into_iter().collect()),
585 })
586 }
587
588 pub(crate) fn requests(&self) -> Vec<HttpRequest> {
590 self.seen
591 .lock()
592 .unwrap_or_else(PoisonError::into_inner)
593 .clone()
594 }
595
596 pub(crate) fn last(&self) -> HttpRequest {
597 self.requests()
598 .pop()
599 .expect("the recorder saw no request at all")
600 }
601
602 fn record(&self, request: HttpRequest) -> Reply {
603 self.seen
604 .lock()
605 .unwrap_or_else(PoisonError::into_inner)
606 .push(request.clone());
607 self.replies
608 .lock()
609 .unwrap_or_else(PoisonError::into_inner)
610 .pop_front()
611 .unwrap_or_else(|| {
612 panic!(
613 "the recorder ran out of replies at {} {}",
614 request.method, request.path
615 )
616 })
617 }
618 }
619
620 struct Queued(std::collections::VecDeque<Vec<u8>>);
621
622 impl ChunkSource for Queued {
623 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
624 Box::pin(async move { Ok(self.0.pop_front()) })
625 }
626 }
627
628 impl HttpBackend for Recorder {
629 fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
630 let reply = self.record(request);
631 Box::pin(async move {
632 match reply {
633 Reply::Body(status, body) => Ok(HttpResponse {
634 status,
635 headers: HashMap::new(),
636 body,
637 }),
638 Reply::Chunks(status, chunks) => Ok(HttpResponse {
639 status,
640 headers: HashMap::new(),
641 body: chunks.concat(),
642 }),
643 Reply::Cut(why) => Err(Error::wire(WireKind::Transport, why)),
644 }
645 })
646 }
647
648 fn open_stream(
649 &self,
650 request: HttpRequest,
651 _idle_timeout: Duration,
652 ) -> BoxFuture<'_, Result<OpenStream, Error>> {
653 let reply = self.record(request);
654 Box::pin(async move {
655 let (status, chunks) = match reply {
656 Reply::Chunks(status, chunks) => (status, chunks),
657 Reply::Body(status, body) => (status, vec![body]),
658 Reply::Cut(why) => return Err(Error::wire(WireKind::Transport, why)),
659 };
660 let head = HttpResponse {
661 status,
662 headers: HashMap::new(),
663 body: if (200..300).contains(&status) {
666 Vec::new()
667 } else {
668 chunks.concat()
669 },
670 };
671 let source: Box<dyn ChunkSource> = if (200..300).contains(&status) {
672 Box::new(Queued(chunks.into_iter().collect()))
673 } else {
674 Box::new(Queued(std::collections::VecDeque::new()))
675 };
676 Ok((head, source))
677 })
678 }
679 }
680
681 pub(crate) fn session_with(
683 recorder: Arc<Recorder>,
684 ) -> (Session, Arc<ProxyAuth>, Arc<proxy::testing::ManualClock>) {
685 let clock = Arc::new(proxy::testing::ManualClock::default());
686 let auth = Arc::new(
687 ProxyAuth::with_refresh_after(
688 Arc::new(proxy::testing::CountingMinter::default()),
689 DEFAULT_AGENT_PORT,
690 DEFAULT_REFRESH_AFTER,
691 Arc::clone(&clock) as Arc<dyn Clock>,
692 )
693 .expect("the default interval is below the ceiling"),
694 );
695 let session = Session::builder("https://vm.example", "agent-token-abcdef")
696 .with_backend(recorder)
697 .with_proxy_auth(Arc::clone(&auth))
698 .build()
699 .expect("builds");
700 (session, auth, clock)
701 }
702
703 pub(crate) fn health_body(bootstrapped: bool) -> serde_json::Value {
704 serde_json::json!({
705 "version": "0.1.0",
706 "bootstrapped": bootstrapped,
707 "disk": null,
708 "identity_degraded": false,
709 "identity_repaired": true,
710 })
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::testing::{Recorder, Reply, health_body, session_with};
717 use super::*;
718 use crate::session::proxy::testing::CountingMinter;
719
720 fn start_request(exec_id: &str) -> protocol::exec::StartRequest {
721 protocol::exec::StartRequest {
722 exec_id: exec_id.to_string(),
723 command: vec!["/bin/true".to_string()],
724 shell: false,
725 cwd: None,
726 env: Default::default(),
727 user: None,
728 group: None,
729 timeout_sec: None,
730 stdin: false,
731 }
732 }
733
734 fn header(request: &HttpRequest, name: &str) -> Option<String> {
735 request.header(name).map(str::to_string)
736 }
737
738 fn header_of(headers: &[(String, String)], name: &str) -> Option<String> {
740 headers
741 .iter()
742 .find(|(key, _)| key.eq_ignore_ascii_case(name))
743 .map(|(_, value)| value.clone())
744 }
745
746 #[tokio::test]
754 async fn a_callers_content_type_survives_the_auth_header_injection() {
755 let recorder = Recorder::with([Reply::ok(
756 serde_json::json!({"exec_id":"e1","phase":"running"}),
757 )]);
758 let (session, _, _) = session_with(Arc::clone(&recorder));
759
760 session.run(start_request("e1")).await.expect("start");
761
762 let seen = recorder.requests();
763 assert_eq!(
764 header(&seen[0], "content-type").as_deref(),
765 Some("application/json"),
766 "the exec start lost its content type to the auth header injection"
767 );
768 }
769
770 #[tokio::test]
775 async fn the_session_hands_back_the_token_it_authenticates_with() {
776 let recorder = Recorder::with([]);
777 let (session, _, _) = session_with(Arc::clone(&recorder));
778 assert_eq!(session.agent_token(), "agent-token-abcdef");
779 }
780
781 #[tokio::test]
788 async fn every_endpoint_request_carries_both_proxy_headers() {
789 let recorder = Recorder::with([
790 Reply::ok(health_body(true)),
791 Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
792 ]);
793 let (session, _, _) = session_with(Arc::clone(&recorder));
794
795 session.health().await.expect("health succeeds");
796 session.run(start_request("e1")).await.expect("start");
797
798 let seen = recorder.requests();
799 assert_eq!(seen.len(), 2);
800 for request in &seen {
801 assert_eq!(
802 header(request, PROXY_AUTH_HEADER).as_deref(),
803 Some(CountingMinter::value(0).as_str()),
804 "{} {} went out without the proxy auth header",
805 request.method,
806 request.path
807 );
808 assert_eq!(
809 header(request, PROXY_PORT_HEADER).as_deref(),
810 Some("9000"),
811 "{} {} went out without the proxy port header, which the proxy \
812 rejects as if the token were bad",
813 request.method,
814 request.path
815 );
816 }
817 }
818
819 #[tokio::test]
825 async fn health_sends_no_bearer_and_an_exec_route_does() {
826 let recorder = Recorder::with([
827 Reply::ok(health_body(true)),
828 Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
829 ]);
830 let (session, _, _) = session_with(Arc::clone(&recorder));
831
832 session.health().await.expect("health");
833 let seen = recorder.requests();
834 assert_eq!(header(&seen[0], "authorization"), None);
835 assert_eq!(
836 header(&seen[0], "x-microvms-core-token-intent"),
837 None,
838 "the internal token-intent marker must not reach the wire"
839 );
840
841 session.run(start_request("e1")).await.expect("start");
842 assert_eq!(
843 header(&recorder.last(), "authorization").as_deref(),
844 Some("Bearer agent-token-abcdef")
845 );
846 }
847
848 #[tokio::test]
851 async fn a_direct_session_sends_no_proxy_headers() {
852 let recorder = Recorder::with([Reply::ok(health_body(true))]);
853 let session = Session::builder("http://127.0.0.1:9000", "token")
854 .with_backend(Arc::clone(&recorder) as http::SharedBackend)
855 .build()
856 .expect("builds");
857 assert!(session.proxy_auth().is_none());
858
859 session.health().await.expect("health");
860 let request = recorder.last();
861 assert_eq!(header(&request, PROXY_AUTH_HEADER), None);
862 assert_eq!(header(&request, PROXY_PORT_HEADER), None);
863 }
864
865 #[tokio::test]
868 async fn a_mint_failure_surfaces_as_a_retryable_request_error() {
869 let recorder = Recorder::with([Reply::ok(health_body(true))]);
870 let auth = Arc::new(
871 ProxyAuth::with_refresh_after(
872 Arc::new(CountingMinter::failing(1)),
873 DEFAULT_AGENT_PORT,
874 DEFAULT_REFRESH_AFTER,
875 Arc::new(proxy::testing::ManualClock::default()),
876 )
877 .expect("interval accepted"),
878 );
879 let session = Session::builder("https://vm.example", "token")
880 .with_backend(Arc::clone(&recorder) as http::SharedBackend)
881 .with_proxy_auth(Arc::clone(&auth))
882 .build()
883 .expect("builds");
884
885 let err = session.health().await.expect_err("the mint fails");
886 assert_eq!(err.kind(), ErrorKind::Retryable);
887 assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
888 assert!(
889 recorder.requests().is_empty(),
890 "a request went out without a token"
891 );
892
893 session.health().await.expect("the retry succeeds");
894 assert_eq!(auth.mint_count(), 1);
895 }
896
897 #[tokio::test]
900 async fn a_daemon_status_arrives_as_its_typed_error() {
901 for (status, expected) in [
902 (401, WireKind::Unauthorized),
903 (404, WireKind::NotFound),
904 (409, WireKind::Conflict),
905 (503, WireKind::NotBootstrapped),
906 ] {
907 let recorder = Recorder::with([Reply::Body(status, b"{\"error\":\"x\"}".to_vec())]);
908 let (session, _, _) = session_with(recorder);
909 let err = session.health().await.expect_err("a failing status");
910 assert_eq!(err.wire_kind(), Some(expected), "status {status}");
911 }
912 }
913
914 #[tokio::test(start_paused = true)]
917 async fn wait_until_ready_polls_through_not_bootstrapped_and_a_dropped_connection() {
918 let recorder = Recorder::with([
919 Reply::Cut("connection refused"),
920 Reply::Body(503, b"not bootstrapped".to_vec()),
921 Reply::ok(health_body(false)),
922 Reply::ok(health_body(true)),
923 ]);
924 let (session, _, _) = session_with(Arc::clone(&recorder));
925
926 let health = session
927 .wait_until_ready(DEFAULT_READY_TIMEOUT)
928 .await
929 .expect("the daemon comes up");
930 assert!(health.bootstrapped);
931 assert_eq!(recorder.requests().len(), 4);
932 }
933
934 #[tokio::test(start_paused = true)]
937 async fn wait_until_ready_gives_up_at_once_on_a_credential_failure() {
938 let recorder = Recorder::with([Reply::Body(401, b"wrong token".to_vec())]);
939 let (session, _, _) = session_with(Arc::clone(&recorder));
940
941 let err = session
942 .wait_until_ready(DEFAULT_READY_TIMEOUT)
943 .await
944 .expect_err("a 401 is fatal");
945 assert_eq!(err.kind(), ErrorKind::Credentials);
946 assert_eq!(
947 recorder.requests().len(),
948 1,
949 "a fatal error was retried, so a wrong token costs the full timeout"
950 );
951 }
952
953 #[tokio::test(start_paused = true)]
956 async fn wait_until_ready_times_out_naming_the_last_retryable_error() {
957 let recorder = Recorder::with([
958 Reply::Cut("connection refused"),
959 Reply::Cut("connection refused"),
960 Reply::Cut("connection refused"),
961 ]);
962 let (session, _, _) = session_with(recorder);
963
964 let err = session
965 .wait_until_ready(Duration::from_secs(4))
966 .await
967 .expect_err("the daemon never comes up");
968 assert_eq!(err.kind(), ErrorKind::Timeout);
969 assert!(
970 err.to_string().contains("connection refused"),
971 "the timeout must name what it kept hitting: {err}"
972 );
973 }
974
975 #[tokio::test]
981 async fn rebind_invalidates_the_proxy_token_so_the_next_request_mints() {
982 let recorder = Recorder::with([Reply::ok(health_body(true)), Reply::ok(health_body(true))]);
983 let (mut session, auth, _) = session_with(Arc::clone(&recorder));
984
985 session.health().await.expect("health");
986 assert_eq!(auth.mint_count(), 1);
987
988 session.rebind("https://vm.example".to_string());
989 assert!(!auth.is_cached(), "rebind did not drop the token");
990
991 session.health().await.expect("health");
992 assert_eq!(auth.mint_count(), 2);
993 assert_eq!(
994 header(&recorder.last(), PROXY_AUTH_HEADER).as_deref(),
995 Some(CountingMinter::value(1).as_str()),
996 "the request after a rebind carried the pre-suspend token"
997 );
998 }
999
1000 #[tokio::test]
1003 async fn a_start_request_serializes_through_the_protocol_crate() {
1004 let recorder = Recorder::with([Reply::ok(
1005 serde_json::json!({"exec_id":"e-confirmed","phase":"running"}),
1006 )]);
1007 let (session, _, _) = session_with(Arc::clone(&recorder));
1008
1009 let handle = session.run(start_request("e-asked")).await.expect("start");
1010 assert_eq!(
1011 handle.exec_id(),
1012 "e-confirmed",
1013 "the handle addresses the id the client asked for rather than the one the \
1014 daemon confirmed, so every later call would 404 if they diverged"
1015 );
1016
1017 let request = recorder.last();
1018 assert_eq!(request.method, "POST");
1019 assert_eq!(request.path, "/v1/exec/start");
1020 let body: serde_json::Value = serde_json::from_slice(&request.body).expect("json body");
1021 assert_eq!(body["exec_id"], "e-asked");
1022 assert_eq!(body["command"], serde_json::json!(["/bin/true"]));
1023 }
1024
1025 #[tokio::test]
1028 async fn an_unreadable_response_body_names_the_schema_route() {
1029 let recorder = Recorder::with([Reply::Body(200, b"{\"unexpected\":true}".to_vec())]);
1030 let (session, _, _) = session_with(recorder);
1031 let err = session.health().await.expect_err("the body will not parse");
1032 assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
1033 assert!(err.to_string().contains("/v1/schema"), "{err}");
1034 }
1035
1036 #[tokio::test]
1057 async fn a_session_answers_connect_credentials_for_a_port_off_its_own_token_cache() {
1058 let recorder = Recorder::with([Reply::ok(health_body(true))]);
1059 let (session, auth, _) = session_with(Arc::clone(&recorder));
1060
1061 session.health().await.expect("health warms the cache");
1062 assert_eq!(auth.mint_count(), 1);
1063
1064 let headers = session
1065 .connect_headers(8080)
1066 .await
1067 .expect("the cached token answers");
1068 let offered = session
1069 .connect_subprotocols(8080)
1070 .await
1071 .expect("the cached token answers")
1072 .expect("a proxied session offers subprotocols");
1073
1074 assert_eq!(
1075 header_of(&headers, PROXY_PORT_HEADER).as_deref(),
1076 Some("8080"),
1077 "the caller asked for 8080 and would have targeted the session's port"
1078 );
1079 assert_eq!(
1081 header_of(&headers, PROXY_AUTH_HEADER).as_deref(),
1082 Some(CountingMinter::value(1).as_str()),
1083 "the header pair carried the agent-port-only token, which the proxy refuses for \
1084 8080 with 403 'Access to port denied'"
1085 );
1086 assert_eq!(offered[0], proxy::WS_SUBPROTOCOL);
1087 assert_eq!(
1088 offered[1],
1089 format!(
1090 "{}{}",
1091 proxy::WS_AUTH_SUBPROTOCOL_PREFIX,
1092 CountingMinter::value(1)
1093 ),
1094 "the handshake carried a different token than the header path, so they are two \
1095 caches rather than one"
1096 );
1097 assert_eq!(offered[2], "lambda-microvms.port.8080");
1098 assert_eq!(
1099 auth.mint_count(),
1100 2,
1101 "one mint covered both the agent port and 8080, which the control plane does not \
1102 do — or the second accessor minted a third, which would be two caches"
1103 );
1104 assert!(
1105 recorder.requests().len() == 1,
1106 "building credentials sent a request to the VM: {:?}",
1107 recorder.requests()
1108 );
1109 }
1110
1111 #[tokio::test]
1122 async fn a_direct_session_offers_no_subprotocols_and_empty_headers() {
1123 let recorder = Recorder::with([]);
1124 let session = Session::builder("http://127.0.0.1:9000", "token")
1125 .with_backend(recorder as http::SharedBackend)
1126 .build()
1127 .expect("builds");
1128
1129 assert!(
1130 session
1131 .connect_headers(9000)
1132 .await
1133 .expect("no proxy is not a failure")
1134 .is_empty(),
1135 "a direct session's requests carry no proxy headers, so neither should this"
1136 );
1137 assert!(
1138 session
1139 .connect_subprotocols(9000)
1140 .await
1141 .expect("no proxy is not a failure")
1142 .is_none(),
1143 "a handshake with the base subprotocol and no token is refused for a reason that \
1144 names neither"
1145 );
1146 }
1147
1148 #[tokio::test]
1159 async fn connect_credentials_re_mint_when_the_refresh_window_has_rolled_over() {
1160 let recorder = Recorder::with([]);
1161 let (session, auth, clock) = session_with(recorder);
1162
1163 let first = session
1164 .connect_subprotocols(9000)
1165 .await
1166 .expect("mints")
1167 .expect("proxied");
1168 assert!(first[1].ends_with(&CountingMinter::value(0)));
1169
1170 clock.advance(DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
1171 let second = session
1172 .connect_subprotocols(9000)
1173 .await
1174 .expect("re-mints")
1175 .expect("proxied");
1176
1177 assert_eq!(auth.mint_count(), 2);
1178 assert!(
1179 second[1].ends_with(&CountingMinter::value(1)),
1180 "the handshake would carry a token past its refresh window: {}",
1181 second[1]
1182 );
1183 }
1184
1185 #[test]
1187 fn a_session_debug_does_not_print_the_agent_token() {
1188 let recorder = Recorder::with([]);
1189 let session = Session::builder("https://vm.example", "super-secret-token")
1190 .with_backend(recorder as http::SharedBackend)
1191 .build()
1192 .expect("builds");
1193 let rendered = format!("{session:?}");
1194 assert!(rendered.contains("vm.example"), "{rendered}");
1195 assert!(
1196 !rendered.contains("super-secret"),
1197 "the agent token reached a Debug string: {rendered}"
1198 );
1199 }
1200}