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