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