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 procs(&self) -> Result<protocol::exec::ProcsResponse, Error> {
441 self.transport
442 .send_json(HttpRequest::new("GET", "/v1/procs"))
443 .await
444 }
445
446 pub async fn upload_file(
450 &self,
451 path: &str,
452 data: &[u8],
453 mode: Option<&str>,
454 ) -> Result<(), Error> {
455 files::upload_file(&self.transport, path, data, mode).await
456 }
457
458 pub async fn download_file(&self, path: &str) -> Result<Vec<u8>, Error> {
460 files::download_file(&self.transport, path).await
461 }
462
463 pub async fn file_exists(&self, path: &str) -> Result<bool, Error> {
465 files::file_exists(&self.transport, path).await
466 }
467
468 pub async fn upload_tar(&self, remote: &str, archive: &[u8]) -> Result<(), Error> {
470 files::upload_tar(&self.transport, remote, archive).await
471 }
472
473 pub async fn download_tar(&self, remote: &str) -> Result<Vec<u8>, Error> {
475 files::download_tar(&self.transport, remote).await
476 }
477}
478
479impl std::fmt::Debug for Session {
480 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
481 f.debug_struct("Session")
483 .field("endpoint", &self.endpoint)
484 .field("port", &self.port)
485 .field("proxy", &self.transport.proxy())
486 .finish()
487 }
488}
489
490pub struct SessionBuilder {
492 endpoint: String,
493 agent_token: String,
494 minter: Option<Arc<dyn TokenMinter>>,
495 backend: Option<http::SharedBackend>,
496 port: u16,
497 timeout: Duration,
498 proxy: Option<Arc<ProxyAuth>>,
499}
500
501impl SessionBuilder {
502 #[must_use]
504 pub fn with_minter(mut self, minter: Arc<dyn TokenMinter>) -> Self {
505 self.minter = Some(minter);
506 self
507 }
508
509 #[must_use]
512 pub fn with_proxy_auth(mut self, proxy: Arc<ProxyAuth>) -> Self {
513 self.proxy = Some(proxy);
514 self
515 }
516
517 #[must_use]
519 pub fn with_backend(mut self, backend: http::SharedBackend) -> Self {
520 self.backend = Some(backend);
521 self
522 }
523
524 #[must_use]
526 pub fn with_port(mut self, port: u16) -> Self {
527 self.port = port;
528 self
529 }
530
531 #[must_use]
533 pub fn with_timeout(mut self, timeout: Duration) -> Self {
534 self.timeout = timeout;
535 self
536 }
537
538 pub fn build(self) -> Result<Session, Error> {
540 let backend = match self.backend {
541 Some(backend) => backend,
542 None => Arc::new(ReqwestBackend::new(&self.endpoint, self.timeout)?),
543 };
544 let proxy = match (self.proxy, self.minter) {
545 (Some(proxy), _) => Some(proxy),
546 (None, Some(minter)) => Some(Arc::new(ProxyAuth::new(minter, self.port))),
547 (None, None) => None,
548 };
549 Ok(Session {
550 transport: Arc::new(Transport {
551 backend,
552 agent_token: self.agent_token,
553 proxy,
554 timeout: self.timeout,
555 }),
556 endpoint: self.endpoint,
557 port: self.port,
558 })
559 }
560}
561
562#[cfg(test)]
563pub(crate) mod testing {
564 use std::collections::HashMap;
572 use std::sync::{Mutex, PoisonError};
573
574 use futures_util::future::BoxFuture;
575
576 use super::http::{ChunkSource, HttpBackend, HttpRequest, HttpResponse, OpenStream};
577 use super::*;
578
579 pub(crate) enum Reply {
581 Body(u16, Vec<u8>),
583 Chunks(u16, Vec<Vec<u8>>),
585 Cut(&'static str),
587 }
588
589 impl Reply {
590 pub(crate) fn json(status: u16, body: impl serde::Serialize) -> Self {
591 Reply::Body(status, serde_json::to_vec(&body).expect("test body"))
592 }
593
594 pub(crate) fn ok(body: impl serde::Serialize) -> Self {
595 Reply::json(200, body)
596 }
597 }
598
599 #[derive(Default)]
600 pub(crate) struct Recorder {
601 seen: Mutex<Vec<HttpRequest>>,
602 replies: Mutex<std::collections::VecDeque<Reply>>,
603 }
604
605 impl Recorder {
606 pub(crate) fn with(replies: impl IntoIterator<Item = Reply>) -> Arc<Self> {
607 Arc::new(Self {
608 seen: Mutex::new(Vec::new()),
609 replies: Mutex::new(replies.into_iter().collect()),
610 })
611 }
612
613 pub(crate) fn requests(&self) -> Vec<HttpRequest> {
615 self.seen
616 .lock()
617 .unwrap_or_else(PoisonError::into_inner)
618 .clone()
619 }
620
621 pub(crate) fn last(&self) -> HttpRequest {
622 self.requests()
623 .pop()
624 .expect("the recorder saw no request at all")
625 }
626
627 fn record(&self, request: HttpRequest) -> Reply {
628 self.seen
629 .lock()
630 .unwrap_or_else(PoisonError::into_inner)
631 .push(request.clone());
632 self.replies
633 .lock()
634 .unwrap_or_else(PoisonError::into_inner)
635 .pop_front()
636 .unwrap_or_else(|| {
637 panic!(
638 "the recorder ran out of replies at {} {}",
639 request.method, request.path
640 )
641 })
642 }
643 }
644
645 struct Queued(std::collections::VecDeque<Vec<u8>>);
646
647 impl ChunkSource for Queued {
648 fn next_chunk(&mut self) -> BoxFuture<'_, Result<Option<Vec<u8>>, Error>> {
649 Box::pin(async move { Ok(self.0.pop_front()) })
650 }
651 }
652
653 impl HttpBackend for Recorder {
654 fn send(&self, request: HttpRequest) -> BoxFuture<'_, Result<HttpResponse, Error>> {
655 let reply = self.record(request);
656 Box::pin(async move {
657 match reply {
658 Reply::Body(status, body) => Ok(HttpResponse {
659 status,
660 headers: HashMap::new(),
661 body,
662 }),
663 Reply::Chunks(status, chunks) => Ok(HttpResponse {
664 status,
665 headers: HashMap::new(),
666 body: chunks.concat(),
667 }),
668 Reply::Cut(why) => Err(Error::wire(WireKind::Transport, why)),
669 }
670 })
671 }
672
673 fn open_stream(
674 &self,
675 request: HttpRequest,
676 _idle_timeout: Duration,
677 ) -> BoxFuture<'_, Result<OpenStream, Error>> {
678 let reply = self.record(request);
679 Box::pin(async move {
680 let (status, chunks) = match reply {
681 Reply::Chunks(status, chunks) => (status, chunks),
682 Reply::Body(status, body) => (status, vec![body]),
683 Reply::Cut(why) => return Err(Error::wire(WireKind::Transport, why)),
684 };
685 let head = HttpResponse {
686 status,
687 headers: HashMap::new(),
688 body: if (200..300).contains(&status) {
691 Vec::new()
692 } else {
693 chunks.concat()
694 },
695 };
696 let source: Box<dyn ChunkSource> = if (200..300).contains(&status) {
697 Box::new(Queued(chunks.into_iter().collect()))
698 } else {
699 Box::new(Queued(std::collections::VecDeque::new()))
700 };
701 Ok((head, source))
702 })
703 }
704 }
705
706 pub(crate) fn session_with(
708 recorder: Arc<Recorder>,
709 ) -> (Session, Arc<ProxyAuth>, Arc<proxy::testing::ManualClock>) {
710 let clock = Arc::new(proxy::testing::ManualClock::default());
711 let auth = Arc::new(
712 ProxyAuth::with_refresh_after(
713 Arc::new(proxy::testing::CountingMinter::default()),
714 DEFAULT_AGENT_PORT,
715 DEFAULT_REFRESH_AFTER,
716 Arc::clone(&clock) as Arc<dyn Clock>,
717 )
718 .expect("the default interval is below the ceiling"),
719 );
720 let session = Session::builder("https://vm.example", "agent-token-abcdef")
721 .with_backend(recorder)
722 .with_proxy_auth(Arc::clone(&auth))
723 .build()
724 .expect("builds");
725 (session, auth, clock)
726 }
727
728 pub(crate) fn health_body(bootstrapped: bool) -> serde_json::Value {
729 serde_json::json!({
730 "version": "0.1.0",
731 "bootstrapped": bootstrapped,
732 "disk": null,
733 "identity_degraded": false,
734 "identity_repaired": true,
735 })
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::testing::{Recorder, Reply, health_body, session_with};
742 use super::*;
743 use crate::session::proxy::testing::CountingMinter;
744
745 fn start_request(exec_id: &str) -> protocol::exec::StartRequest {
746 protocol::exec::StartRequest {
747 exec_id: exec_id.to_string(),
748 command: vec!["/bin/true".to_string()],
749 shell: false,
750 cwd: None,
751 env: Default::default(),
752 user: None,
753 group: None,
754 timeout_sec: None,
755 stdin: false,
756 reap_group_on_exit: false,
757 }
758 }
759
760 fn header(request: &HttpRequest, name: &str) -> Option<String> {
761 request.header(name).map(str::to_string)
762 }
763
764 fn header_of(headers: &[(String, String)], name: &str) -> Option<String> {
766 headers
767 .iter()
768 .find(|(key, _)| key.eq_ignore_ascii_case(name))
769 .map(|(_, value)| value.clone())
770 }
771
772 #[tokio::test]
780 async fn a_callers_content_type_survives_the_auth_header_injection() {
781 let recorder = Recorder::with([Reply::ok(
782 serde_json::json!({"exec_id":"e1","phase":"running"}),
783 )]);
784 let (session, _, _) = session_with(Arc::clone(&recorder));
785
786 session.run(start_request("e1")).await.expect("start");
787
788 let seen = recorder.requests();
789 assert_eq!(
790 header(&seen[0], "content-type").as_deref(),
791 Some("application/json"),
792 "the exec start lost its content type to the auth header injection"
793 );
794 }
795
796 #[tokio::test]
801 async fn the_session_hands_back_the_token_it_authenticates_with() {
802 let recorder = Recorder::with([]);
803 let (session, _, _) = session_with(Arc::clone(&recorder));
804 assert_eq!(session.agent_token(), "agent-token-abcdef");
805 }
806
807 #[tokio::test]
814 async fn every_endpoint_request_carries_both_proxy_headers() {
815 let recorder = Recorder::with([
816 Reply::ok(health_body(true)),
817 Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
818 ]);
819 let (session, _, _) = session_with(Arc::clone(&recorder));
820
821 session.health().await.expect("health succeeds");
822 session.run(start_request("e1")).await.expect("start");
823
824 let seen = recorder.requests();
825 assert_eq!(seen.len(), 2);
826 for request in &seen {
827 assert_eq!(
828 header(request, PROXY_AUTH_HEADER).as_deref(),
829 Some(CountingMinter::value(0).as_str()),
830 "{} {} went out without the proxy auth header",
831 request.method,
832 request.path
833 );
834 assert_eq!(
835 header(request, PROXY_PORT_HEADER).as_deref(),
836 Some("9000"),
837 "{} {} went out without the proxy port header, which the proxy \
838 rejects as if the token were bad",
839 request.method,
840 request.path
841 );
842 }
843 }
844
845 #[tokio::test]
851 async fn health_sends_no_bearer_and_an_exec_route_does() {
852 let recorder = Recorder::with([
853 Reply::ok(health_body(true)),
854 Reply::ok(serde_json::json!({"exec_id":"e1","phase":"running"})),
855 ]);
856 let (session, _, _) = session_with(Arc::clone(&recorder));
857
858 session.health().await.expect("health");
859 let seen = recorder.requests();
860 assert_eq!(header(&seen[0], "authorization"), None);
861 assert_eq!(
862 header(&seen[0], "x-microvms-core-token-intent"),
863 None,
864 "the internal token-intent marker must not reach the wire"
865 );
866
867 session.run(start_request("e1")).await.expect("start");
868 assert_eq!(
869 header(&recorder.last(), "authorization").as_deref(),
870 Some("Bearer agent-token-abcdef")
871 );
872 }
873
874 #[tokio::test]
877 async fn a_direct_session_sends_no_proxy_headers() {
878 let recorder = Recorder::with([Reply::ok(health_body(true))]);
879 let session = Session::builder("http://127.0.0.1:9000", "token")
880 .with_backend(Arc::clone(&recorder) as http::SharedBackend)
881 .build()
882 .expect("builds");
883 assert!(session.proxy_auth().is_none());
884
885 session.health().await.expect("health");
886 let request = recorder.last();
887 assert_eq!(header(&request, PROXY_AUTH_HEADER), None);
888 assert_eq!(header(&request, PROXY_PORT_HEADER), None);
889 }
890
891 #[tokio::test]
894 async fn a_mint_failure_surfaces_as_a_retryable_request_error() {
895 let recorder = Recorder::with([Reply::ok(health_body(true))]);
896 let auth = Arc::new(
897 ProxyAuth::with_refresh_after(
898 Arc::new(CountingMinter::failing(1)),
899 DEFAULT_AGENT_PORT,
900 DEFAULT_REFRESH_AFTER,
901 Arc::new(proxy::testing::ManualClock::default()),
902 )
903 .expect("interval accepted"),
904 );
905 let session = Session::builder("https://vm.example", "token")
906 .with_backend(Arc::clone(&recorder) as http::SharedBackend)
907 .with_proxy_auth(Arc::clone(&auth))
908 .build()
909 .expect("builds");
910
911 let err = session.health().await.expect_err("the mint fails");
912 assert_eq!(err.kind(), ErrorKind::Retryable);
913 assert_eq!(err.wire_kind(), Some(WireKind::AuthTokenMint));
914 assert!(
915 recorder.requests().is_empty(),
916 "a request went out without a token"
917 );
918
919 session.health().await.expect("the retry succeeds");
920 assert_eq!(auth.mint_count(), 1);
921 }
922
923 #[tokio::test]
926 async fn a_daemon_status_arrives_as_its_typed_error() {
927 for (status, expected) in [
928 (401, WireKind::Unauthorized),
929 (404, WireKind::NotFound),
930 (409, WireKind::Conflict),
931 (503, WireKind::NotBootstrapped),
932 ] {
933 let recorder = Recorder::with([Reply::Body(status, b"{\"error\":\"x\"}".to_vec())]);
934 let (session, _, _) = session_with(recorder);
935 let err = session.health().await.expect_err("a failing status");
936 assert_eq!(err.wire_kind(), Some(expected), "status {status}");
937 }
938 }
939
940 #[tokio::test(start_paused = true)]
943 async fn wait_until_ready_polls_through_not_bootstrapped_and_a_dropped_connection() {
944 let recorder = Recorder::with([
945 Reply::Cut("connection refused"),
946 Reply::Body(503, b"not bootstrapped".to_vec()),
947 Reply::ok(health_body(false)),
948 Reply::ok(health_body(true)),
949 ]);
950 let (session, _, _) = session_with(Arc::clone(&recorder));
951
952 let health = session
953 .wait_until_ready(DEFAULT_READY_TIMEOUT)
954 .await
955 .expect("the daemon comes up");
956 assert!(health.bootstrapped);
957 assert_eq!(recorder.requests().len(), 4);
958 }
959
960 #[tokio::test(start_paused = true)]
963 async fn wait_until_ready_gives_up_at_once_on_a_credential_failure() {
964 let recorder = Recorder::with([Reply::Body(401, b"wrong token".to_vec())]);
965 let (session, _, _) = session_with(Arc::clone(&recorder));
966
967 let err = session
968 .wait_until_ready(DEFAULT_READY_TIMEOUT)
969 .await
970 .expect_err("a 401 is fatal");
971 assert_eq!(err.kind(), ErrorKind::Credentials);
972 assert_eq!(
973 recorder.requests().len(),
974 1,
975 "a fatal error was retried, so a wrong token costs the full timeout"
976 );
977 }
978
979 #[tokio::test(start_paused = true)]
982 async fn wait_until_ready_times_out_naming_the_last_retryable_error() {
983 let recorder = Recorder::with([
984 Reply::Cut("connection refused"),
985 Reply::Cut("connection refused"),
986 Reply::Cut("connection refused"),
987 ]);
988 let (session, _, _) = session_with(recorder);
989
990 let err = session
991 .wait_until_ready(Duration::from_secs(4))
992 .await
993 .expect_err("the daemon never comes up");
994 assert_eq!(err.kind(), ErrorKind::Timeout);
995 assert!(
996 err.to_string().contains("connection refused"),
997 "the timeout must name what it kept hitting: {err}"
998 );
999 }
1000
1001 #[tokio::test]
1007 async fn rebind_invalidates_the_proxy_token_so_the_next_request_mints() {
1008 let recorder = Recorder::with([Reply::ok(health_body(true)), Reply::ok(health_body(true))]);
1009 let (mut session, auth, _) = session_with(Arc::clone(&recorder));
1010
1011 session.health().await.expect("health");
1012 assert_eq!(auth.mint_count(), 1);
1013
1014 session.rebind("https://vm.example".to_string());
1015 assert!(!auth.is_cached(), "rebind did not drop the token");
1016
1017 session.health().await.expect("health");
1018 assert_eq!(auth.mint_count(), 2);
1019 assert_eq!(
1020 header(&recorder.last(), PROXY_AUTH_HEADER).as_deref(),
1021 Some(CountingMinter::value(1).as_str()),
1022 "the request after a rebind carried the pre-suspend token"
1023 );
1024 }
1025
1026 #[tokio::test]
1029 async fn a_start_request_serializes_through_the_protocol_crate() {
1030 let recorder = Recorder::with([Reply::ok(
1031 serde_json::json!({"exec_id":"e-confirmed","phase":"running"}),
1032 )]);
1033 let (session, _, _) = session_with(Arc::clone(&recorder));
1034
1035 let handle = session.run(start_request("e-asked")).await.expect("start");
1036 assert_eq!(
1037 handle.exec_id(),
1038 "e-confirmed",
1039 "the handle addresses the id the client asked for rather than the one the \
1040 daemon confirmed, so every later call would 404 if they diverged"
1041 );
1042
1043 let request = recorder.last();
1044 assert_eq!(request.method, "POST");
1045 assert_eq!(request.path, "/v1/exec/start");
1046 let body: serde_json::Value = serde_json::from_slice(&request.body).expect("json body");
1047 assert_eq!(body["exec_id"], "e-asked");
1048 assert_eq!(body["command"], serde_json::json!(["/bin/true"]));
1049 }
1050
1051 #[tokio::test]
1054 async fn an_unreadable_response_body_names_the_schema_route() {
1055 let recorder = Recorder::with([Reply::Body(200, b"{\"unexpected\":true}".to_vec())]);
1056 let (session, _, _) = session_with(recorder);
1057 let err = session.health().await.expect_err("the body will not parse");
1058 assert_eq!(err.wire_kind(), Some(WireKind::ProtocolError));
1059 assert!(err.to_string().contains("/v1/schema"), "{err}");
1060 }
1061
1062 #[tokio::test]
1083 async fn a_session_answers_connect_credentials_for_a_port_off_its_own_token_cache() {
1084 let recorder = Recorder::with([Reply::ok(health_body(true))]);
1085 let (session, auth, _) = session_with(Arc::clone(&recorder));
1086
1087 session.health().await.expect("health warms the cache");
1088 assert_eq!(auth.mint_count(), 1);
1089
1090 let headers = session
1091 .connect_headers(8080)
1092 .await
1093 .expect("the cached token answers");
1094 let offered = session
1095 .connect_subprotocols(8080)
1096 .await
1097 .expect("the cached token answers")
1098 .expect("a proxied session offers subprotocols");
1099
1100 assert_eq!(
1101 header_of(&headers, PROXY_PORT_HEADER).as_deref(),
1102 Some("8080"),
1103 "the caller asked for 8080 and would have targeted the session's port"
1104 );
1105 assert_eq!(
1107 header_of(&headers, PROXY_AUTH_HEADER).as_deref(),
1108 Some(CountingMinter::value(1).as_str()),
1109 "the header pair carried the agent-port-only token, which the proxy refuses for \
1110 8080 with 403 'Access to port denied'"
1111 );
1112 assert_eq!(offered[0], proxy::WS_SUBPROTOCOL);
1113 assert_eq!(
1114 offered[1],
1115 format!(
1116 "{}{}",
1117 proxy::WS_AUTH_SUBPROTOCOL_PREFIX,
1118 CountingMinter::value(1)
1119 ),
1120 "the handshake carried a different token than the header path, so they are two \
1121 caches rather than one"
1122 );
1123 assert_eq!(offered[2], "lambda-microvms.port.8080");
1124 assert_eq!(
1125 auth.mint_count(),
1126 2,
1127 "one mint covered both the agent port and 8080, which the control plane does not \
1128 do — or the second accessor minted a third, which would be two caches"
1129 );
1130 assert!(
1131 recorder.requests().len() == 1,
1132 "building credentials sent a request to the VM: {:?}",
1133 recorder.requests()
1134 );
1135 }
1136
1137 #[tokio::test]
1148 async fn a_direct_session_offers_no_subprotocols_and_empty_headers() {
1149 let recorder = Recorder::with([]);
1150 let session = Session::builder("http://127.0.0.1:9000", "token")
1151 .with_backend(recorder as http::SharedBackend)
1152 .build()
1153 .expect("builds");
1154
1155 assert!(
1156 session
1157 .connect_headers(9000)
1158 .await
1159 .expect("no proxy is not a failure")
1160 .is_empty(),
1161 "a direct session's requests carry no proxy headers, so neither should this"
1162 );
1163 assert!(
1164 session
1165 .connect_subprotocols(9000)
1166 .await
1167 .expect("no proxy is not a failure")
1168 .is_none(),
1169 "a handshake with the base subprotocol and no token is refused for a reason that \
1170 names neither"
1171 );
1172 }
1173
1174 #[tokio::test]
1185 async fn connect_credentials_re_mint_when_the_refresh_window_has_rolled_over() {
1186 let recorder = Recorder::with([]);
1187 let (session, auth, clock) = session_with(recorder);
1188
1189 let first = session
1190 .connect_subprotocols(9000)
1191 .await
1192 .expect("mints")
1193 .expect("proxied");
1194 assert!(first[1].ends_with(&CountingMinter::value(0)));
1195
1196 clock.advance(DEFAULT_REFRESH_AFTER + Duration::from_secs(1));
1197 let second = session
1198 .connect_subprotocols(9000)
1199 .await
1200 .expect("re-mints")
1201 .expect("proxied");
1202
1203 assert_eq!(auth.mint_count(), 2);
1204 assert!(
1205 second[1].ends_with(&CountingMinter::value(1)),
1206 "the handshake would carry a token past its refresh window: {}",
1207 second[1]
1208 );
1209 }
1210
1211 #[tokio::test]
1221 async fn procs_is_a_bearer_get_of_the_procs_route_with_a_typed_body() {
1222 let recorder = Recorder::with([Reply::ok(serde_json::json!({
1223 "procs": [{
1224 "exec_id": "e1",
1225 "pgid": 4242,
1226 "started_at": 1_756_500_000u64,
1227 "child_exited": true,
1228 "reap": false,
1229 "pids": [4243],
1230 }]
1231 }))]);
1232 let (session, _, _) = session_with(Arc::clone(&recorder));
1233
1234 let listed = session.procs().await.expect("procs");
1235 let request = recorder.last();
1236 assert_eq!(request.method, "GET");
1237 assert_eq!(request.path, "/v1/procs");
1238 assert_eq!(
1239 header(&request, "authorization").as_deref(),
1240 Some("Bearer agent-token-abcdef"),
1241 "the procs route is bearer-guarded; without the token it is a 503 or a 401"
1242 );
1243 assert_eq!(listed.procs.len(), 1);
1244 assert_eq!(listed.procs[0].exec_id, "e1");
1245 assert_eq!(listed.procs[0].pgid, Some(4242));
1246 assert!(listed.procs[0].child_exited);
1247 assert_eq!(listed.procs[0].pids, [4243]);
1248 }
1249
1250 #[tokio::test]
1258 async fn a_start_request_carries_the_reap_flag_on_the_wire() {
1259 let recorder = Recorder::with([Reply::ok(
1260 serde_json::json!({"exec_id":"e1","phase":"running"}),
1261 )]);
1262 let (session, _, _) = session_with(Arc::clone(&recorder));
1263
1264 let mut request = start_request("e1");
1265 request.reap_group_on_exit = true;
1266 session.run(request).await.expect("start");
1267
1268 let body: serde_json::Value =
1269 serde_json::from_slice(&recorder.last().body).expect("a JSON start body");
1270 assert_eq!(
1271 body["reap_group_on_exit"],
1272 serde_json::Value::Bool(true),
1273 "the daemon defaults a missing flag to false, so a dropped key is a silent \
1274 behaviour change: {body}"
1275 );
1276 }
1277
1278 #[test]
1280 fn a_session_debug_does_not_print_the_agent_token() {
1281 let recorder = Recorder::with([]);
1282 let session = Session::builder("https://vm.example", "super-secret-token")
1283 .with_backend(recorder as http::SharedBackend)
1284 .build()
1285 .expect("builds");
1286 let rendered = format!("{session:?}");
1287 assert!(rendered.contains("vm.example"), "{rendered}");
1288 assert!(
1289 !rendered.contains("super-secret"),
1290 "the agent token reached a Debug string: {rendered}"
1291 );
1292 }
1293}