microvms_edges/session/tunnel.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The client half of the TCP relay: a local TCP connection carried over a WebSocket.
3//!
4//! What `microvm tunnel` drives. A local listener accepts a connection, this module opens a
5//! `wss://` handshake to the VM's endpoint offering the three port-scoped subprotocols, and
6//! the daemon's `/v1/tcp` route dials `127.0.0.1:<port>` in the guest. Bytes then move in
7//! binary frames until either end closes.
8//!
9//! # Why a real handshake rather than a proxied request
10//!
11//! Measured 2026-08-29 (`docs/PLATFORM.md`): an upgrade re-issued as an ordinary HTTPS `GET`
12//! with `Upgrade: websocket` as a request header is answered **400 by the proxy**, and the
13//! guest logs no handshake at all. The endpoint takes a WebSocket's credential as
14//! `Sec-WebSocket-Protocol` values — because the browser `WebSocket` constructor cannot set
15//! a header — so the HTTPS request path has no way to express an upgrade. That is why this
16//! module exists separately from [`crate::session::forward`], which is an HTTP relay and
17//! cannot carry one.
18//!
19//! # The token is scoped to the daemon's port, not to the guest port
20//!
21//! The inversion worth stating twice, because every other port-scoped call in this crate
22//! names the port it wants to reach. This request terminates at **the daemon**, and the daemon
23//! is what dials the guest port from inside the VM — so the proxy only ever sees a request for
24//! the daemon's port, and a token scoped to the guest port authorizes something this request
25//! never addresses. The guest port travels in the query string instead.
26//!
27//! Getting it backwards produces close code 1006 with no reason, which is indistinguishable
28//! from a dead server; it cost a live debugging session on 2026-08-29 and is why
29//! [`relay_connection`] carries the reason at the call site as well.
30//!
31//! The mint goes through the session's existing [`microvms_app::session::ProxyAuth`] cache either
32//! way, so a tunnel held past the platform's sixty-minute token ceiling refreshes without a
33//! timer here.
34//!
35//! # One connection per WebSocket
36//!
37//! No multiplexing, matching the daemon's side. A mux protocol would need stream ids, flow
38//! control per stream, and a close handshake per stream; the platform already provides all
39//! three per connection. N local connections open N WebSockets.
40//!
41//! # Every platform failure is 1006, so the close code is the only diagnostic
42//!
43//! A refused handshake, a wrong-scope token, and a dead TCP connection all collapse to close
44//! code 1006 with no reason string. The daemon's own codes
45//! ([`protocol::tunnel::close`]) are drawn from RFC 6455's private range precisely so they
46//! can be told apart from that, and [`explain_close`] is where a caller turns one into a
47//! sentence.
48//!
49//! # A verified tunnel's end is proved inside the Noise session
50//!
51//! A close frame is plaintext, and anything on the path can send one or drop the connection.
52//! So a verified tunnel whose daemon offered the end of stream in the handshake ends
53//! [`TunnelEnd::Closed`] only when the daemon's end of stream arrived, or when this side ended
54//! first and sent its own; anything else is [`TunnelEnd::Truncated`] (BIND-23). The rule and its
55//! skew are in `protocol::identity` and `docs/PROTOCOL.md`.
56
57use std::sync::Arc;
58
59use futures_util::{SinkExt as _, StreamExt as _};
60use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncWrite, AsyncWriteExt as _};
61use tokio_tungstenite::tungstenite::client::IntoClientRequest as _;
62use tokio_tungstenite::tungstenite::{Message, protocol::CloseFrame};
63
64use microvms_app::error::{Error, ErrorKind};
65use microvms_app::session::proxy::ProxyAuth;
66
67/// Bytes read from the local connection per relayed frame.
68///
69/// Matches the daemon's own relay chunk, so neither side is the one that fragments a stream.
70pub const TUNNEL_CHUNK_BYTES: usize = 64 * 1024;
71
72/// The daemon's tunnel route.
73const TUNNEL_PATH: &str = "/v1/tcp";
74
75/// Bytes read from the local connection per Noise message on a verified tunnel.
76///
77/// Matches the daemon's `NOISE_CHUNK_BYTES`, and both are below the plain 64 KiB for the
78/// same arithmetic: a Noise transport message carries the ciphertext plus a 16-byte tag
79/// inside one 65535-byte ceiling, and `write_message` refuses a plaintext that does not fit
80/// rather than fragmenting it.
81pub const VERIFIED_CHUNK_BYTES: usize = 32 * 1024;
82
83/// The Noise message ceiling, from the spec's two-byte length prefix.
84const NOISE_MAX_MESSAGE_BYTES: usize = 65535;
85
86/// What ended a tunnel, and whether the caller should treat it as a failure.
87///
88/// Exhaustive on purpose: a caller that matched a new way to end under a wildcard arm would
89/// report a stream cut short however that arm reports, which is the failure #342 fixed.
90#[derive(Clone, Debug, Eq, PartialEq)]
91pub enum TunnelEnd {
92 /// The local client closed, or the guest reached EOF. The tunnel did its job.
93 ///
94 /// On a verified tunnel whose daemon offered the end of stream, the guest's EOF is read
95 /// from the daemon's end of stream, so every byte the guest sent arrived.
96 Closed,
97 /// The daemon refused or lost the guest connection, with its close code.
98 ///
99 /// Carries the code rather than a pre-rendered string so a caller can branch — a dead
100 /// server is worth retrying against a different port, a relay failure is not.
101 Refused { code: u16, reason: String },
102 /// A verified tunnel's daemon offered the end of stream, and the tunnel ended without it:
103 /// a close frame with no failure code, a transport error, or a hangup (BIND-23).
104 ///
105 /// A failure. Every byte that arrived authenticated, but the stream may have been cut short
106 /// on the path, or the guest may have stopped reading before all of the local bytes
107 /// reached it. `code` is the close frame's, and `None` when there was no frame.
108 Truncated { code: Option<u16> },
109 /// A verified tunnel ended the way [`TunnelEnd::Closed`] does, but its daemon predates the
110 /// end of stream (its handshake didn't offer one), so nothing proved the stream finished.
111 ///
112 /// Not a failure: an older daemon never sends an end of stream, and failing here would fail
113 /// every verified tunnel into an older image. A stream cut short would read the same,
114 /// though, so a caller should say so.
115 ClosedUnproven,
116}
117
118/// The `wss://` URL for a tunnel to `guest_port`.
119///
120/// A bare endpoint host is read as `wss`, for the reason
121/// [`crate::session::http::ReqwestBackend`] gives about `https`: the platform hands back a
122/// hostname, and defaulting to plaintext on a missing prefix would put a bearer credential
123/// on the wire in clear.
124pub fn tunnel_url(endpoint: &str, guest_port: u16) -> String {
125 tunnel_url_with_identity(endpoint, guest_port, false)
126}
127
128/// [`tunnel_url`], with the `identity=true` flag when a Noise handshake is wanted.
129pub fn tunnel_url_with_identity(endpoint: &str, guest_port: u16, identity: bool) -> String {
130 let host = endpoint
131 .trim_end_matches('/')
132 .trim_start_matches("https://")
133 .trim_start_matches("http://")
134 .trim_start_matches("wss://")
135 .trim_start_matches("ws://");
136 // `ws` only when the caller explicitly asked for plaintext http, which is the local
137 // daemon case a test uses; anything else is wss.
138 let scheme = if endpoint.starts_with("http://") || endpoint.starts_with("ws://") {
139 "ws"
140 } else {
141 "wss"
142 };
143 let flag = if identity { "&identity=true" } else { "" };
144 format!("{scheme}://{host}{TUNNEL_PATH}?port={guest_port}{flag}")
145}
146
147/// A sentence for a close code, or `None` when the code explains nothing.
148///
149/// Delegates to [`protocol::tunnel::close::explanation`] for the codes the daemon
150/// originates, and adds the one the *platform* produces — 1006, which every proxy-side
151/// failure collapses to. That case needs its own wording precisely because it is ambiguous:
152/// naming a single cause would be a guess presented as a diagnosis.
153pub fn explain_close(code: u16, guest_port: u16) -> Option<String> {
154 if let Some(explanation) = protocol::tunnel::close::explanation(code, guest_port) {
155 return Some(explanation);
156 }
157 if code == 1006 {
158 return Some(format!(
159 "the connection closed abnormally (1006) with no reason, which is what every \
160 endpoint-proxy WebSocket failure looks like: a refused handshake, a token whose \
161 scope does not cover port {guest_port}, and a dropped TCP connection are all \
162 this code. Retry the same port over HTTPS to tell a scope mistake (403) from a \
163 dead server (502)."
164 ));
165 }
166 None
167}
168
169/// Opens a tunnel to `guest_port` and relays `local` over it until either side closes.
170///
171/// `local` is any duplex stream, so a test drives this with an in-memory pipe and the CLI
172/// drives it with a `TcpStream`. Returns how the tunnel ended, which is a value rather than
173/// an error for [`TunnelEnd::Closed`]: a finished tunnel is a success, and a caller that
174/// treated it as a failure would retry a completed request.
175pub async fn relay_connection<S>(
176 local: S,
177 endpoint: &str,
178 guest_port: u16,
179 agent_token: &str,
180 auth: &Arc<ProxyAuth>,
181) -> Result<TunnelEnd, Error>
182where
183 S: AsyncRead + AsyncWrite + Unpin,
184{
185 relay_connection_inner(local, endpoint, guest_port, agent_token, Some(auth), None).await
186}
187
188/// [`relay_connection`], first proving the far end is the VM `identity` was minted for.
189///
190/// The Noise KK handshake runs before any local byte moves: the initiator's message goes
191/// out, the daemon's reply is verified against the pinned VM key, and only then does the
192/// relay start — with every subsequent frame encrypted under the session the handshake
193/// keyed. A refused handshake surfaces as [`TunnelEnd::Refused`] with the daemon's close
194/// code (4403), or as an error naming the failed verification when the daemon's *reply*
195/// does not authenticate — which is the wrong-pin case observed from this side.
196pub async fn relay_connection_verified<S>(
197 local: S,
198 endpoint: &str,
199 guest_port: u16,
200 agent_token: &str,
201 auth: &Arc<ProxyAuth>,
202 identity: µvms_app::identity::TunnelIdentity,
203) -> Result<TunnelEnd, Error>
204where
205 S: AsyncRead + AsyncWrite + Unpin,
206{
207 relay_connection_inner(
208 local,
209 endpoint,
210 guest_port,
211 agent_token,
212 Some(auth),
213 Some(identity),
214 )
215 .await
216}
217
218/// Proves the far end is the VM `identity` was minted for, and nothing more.
219///
220/// The same handshake [`relay_connection_verified`] runs before its first relayed byte — one
221/// code path, so `microvm attach --verify-identity` and `microvm tunnel --verify-identity`
222/// cannot disagree about what "verified" means — with no local connection to relay: the
223/// socket is closed cleanly the moment the daemon's reply authenticates. The guest port is
224/// the daemon's own, because the daemon dials it after the handshake and it is the one port
225/// every VM is guaranteed to have a listener on.
226///
227/// Returns [`TunnelEnd::Closed`] when the pin verified, [`TunnelEnd::Refused`] with the
228/// daemon's close code when it refused (4401 no seed at launch, 4403 refused), and an error
229/// when the daemon's reply did not authenticate against the pin — the wrong-VM case as seen
230/// from this side.
231pub async fn verify_identity(
232 endpoint: &str,
233 agent_token: &str,
234 auth: &Arc<ProxyAuth>,
235 identity: µvms_app::identity::TunnelIdentity,
236) -> Result<TunnelEnd, Error> {
237 let guest_port = auth.port();
238 match open(
239 endpoint,
240 guest_port,
241 agent_token,
242 Some(auth),
243 Some(identity),
244 )
245 .await?
246 {
247 Opened::Refused(end) => Ok(end),
248 Opened::Ready(ready) => {
249 let Ready {
250 mut socket,
251 mut noise,
252 } = *ready;
253 let mut scratch = vec![0_u8; NOISE_MAX_MESSAGE_BYTES];
254 // This side's end of stream first, so the daemon closes its dial to its own port
255 // as a finished stream rather than resetting it.
256 end_first(&mut socket, &mut noise, &mut scratch).await;
257 Ok(TunnelEnd::Closed)
258 }
259 }
260}
261
262/// An open tunnel WebSocket, past the identity handshake when one was asked for.
263///
264/// The ready half is boxed because a WebSocket plus a Noise transport state is several
265/// hundred bytes beside a two-word refusal, and clippy's `large_enum_variant` is right that
266/// every `Refused` would otherwise carry that much padding.
267enum Opened {
268 Ready(Box<Ready>),
269 Refused(TunnelEnd),
270}
271
272struct Ready {
273 socket: TunnelSocket,
274 noise: Option<Verified>,
275}
276
277/// A verified tunnel's session: the keyed transport, and whether the daemon offered the end of
278/// stream in its handshake reply.
279pub(crate) struct Verified {
280 transport: snow::TransportState,
281 pub(crate) daemon_proves_end: bool,
282}
283
284type TunnelSocket =
285 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
286
287/// Opens the tunnel WebSocket to `guest_port` and, when `identity` is given, runs the Noise KK
288/// handshake over it before returning.
289///
290/// Shared by the relay and by [`verify_identity`], so the token scoping, the two credentials
291/// on the upgrade, and the handshake ordering are written once.
292async fn open(
293 endpoint: &str,
294 guest_port: u16,
295 agent_token: &str,
296 auth: Option<&Arc<ProxyAuth>>,
297 identity: Option<µvms_app::identity::TunnelIdentity>,
298) -> Result<Opened, Error> {
299 // **The token is scoped to the DAEMON's port, not to `guest_port`, and that inversion is
300 // the whole subtlety of this route.**
301 //
302 // Every other port-scoped call in this crate names the port it wants to *reach*, so
303 // `subprotocols(guest_port)` reads correct and is wrong: this request terminates at the
304 // daemon, and it is the *daemon* that dials the guest port from inside the VM. The proxy
305 // only ever sees a request for the daemon's port. A token scoped to 5432 therefore
306 // authorizes a port this request never addresses, and the proxy's refusal is close code
307 // 1006 with no reason — indistinguishable from a dead server, which is exactly how this
308 // cost a live debugging session on 2026-08-29.
309 //
310 // `guest_port` still travels, in the query string, where the daemon reads it.
311 //
312 // A direct session has no proxy to cross, so it offers nothing: the daemon answers a
313 // handshake that offered no subprotocol with none, and its own bearer check is the gate.
314 let offered = match auth {
315 Some(auth) => Some(auth.subprotocols(auth.port()).await?),
316 None => None,
317 };
318 let url = tunnel_url_with_identity(endpoint, guest_port, identity.is_some());
319
320 let mut request = url.as_str().into_client_request().map_err(|err| {
321 Error::new(
322 ErrorKind::InvalidArg,
323 format!("{url} is not a usable WebSocket URL: {err}"),
324 )
325 })?;
326 {
327 let headers = request.headers_mut();
328 // The three platform values, offered as one comma-separated list. The proxy consumes
329 // all of them and forwards none (measured), so the daemon sees an ordinary handshake.
330 if let Some(offered) = offered {
331 headers.insert(
332 "sec-websocket-protocol",
333 offered.join(", ").parse().map_err(|err| {
334 Error::new(
335 ErrorKind::Unexpected,
336 format!("the minted subprotocols are not a legal header value: {err}"),
337 )
338 })?,
339 );
340 }
341 // The daemon's own bearer check runs on the upgrade request, so the agent token has
342 // to be here too — the proxy credential authorizes reaching the port, and this
343 // authorizes the daemon's control route behind it. Two credentials, two purposes.
344 headers.insert(
345 "authorization",
346 format!("Bearer {agent_token}").parse().map_err(|err| {
347 Error::new(
348 ErrorKind::Unexpected,
349 format!("the agent token is not a legal header value: {err}"),
350 )
351 })?,
352 );
353 }
354
355 let (mut socket, _response) =
356 tokio_tungstenite::connect_async(request)
357 .await
358 .map_err(|err| {
359 Error::new(
360 ErrorKind::Unexpected,
361 format!(
362 "the tunnel handshake to port {guest_port} failed: {err}. Every \
363 endpoint-proxy WebSocket failure is close code 1006 with no reason, so \
364 if this names nothing, retry the port over HTTPS to tell a scope \
365 mistake (403) from a dead server (502)."
366 ),
367 )
368 })?;
369
370 // The identity handshake, before any local byte moves. Its failure modes split by who
371 // detected them: the daemon's refusal arrives as a close frame (returned as `Refused` so
372 // the CLI renders the code's explanation), and a reply that fails *our* verification is
373 // an error naming the pin — that is the wrong-pin case as seen from this side, and the
374 // one piece of diagnosis the daemon cannot do.
375 let noise = match identity {
376 None => None,
377 Some(identity) => match initiate(&mut socket, identity, guest_port).await? {
378 Initiated::Transport(verified) => Some(verified),
379 Initiated::Refused(end) => return Ok(Opened::Refused(end)),
380 },
381 };
382 Ok(Opened::Ready(Box::new(Ready { socket, noise })))
383}
384
385/// [`relay_connection`] and [`relay_connection_verified`] over an optional proxy credential.
386///
387/// `None` is a direct session's: the daemon is reached without the endpoint proxy, so there
388/// is no port-scoped token to present, and the daemon's bearer check is the gate. The serving
389/// loop (`super::serve`) takes whichever the session has.
390pub(crate) async fn relay_connection_inner<S>(
391 mut local: S,
392 endpoint: &str,
393 guest_port: u16,
394 agent_token: &str,
395 auth: Option<&Arc<ProxyAuth>>,
396 identity: Option<µvms_app::identity::TunnelIdentity>,
397) -> Result<TunnelEnd, Error>
398where
399 S: AsyncRead + AsyncWrite + Unpin,
400{
401 let Ready {
402 mut socket,
403 mut noise,
404 } = match open(endpoint, guest_port, agent_token, auth, identity).await? {
405 Opened::Ready(ready) => *ready,
406 Opened::Refused(end) => return Ok(end),
407 };
408
409 // The verified path reads smaller chunks so plaintext plus the 16-byte tag fits one
410 // Noise message; `write_message` refuses an oversize payload rather than splitting it.
411 let chunk = if noise.is_some() {
412 VERIFIED_CHUNK_BYTES
413 } else {
414 TUNNEL_CHUNK_BYTES
415 };
416 let mut buffer = vec![0_u8; chunk];
417 let mut scratch = vec![0_u8; NOISE_MAX_MESSAGE_BYTES];
418 loop {
419 tokio::select! {
420 // Read from the local client, frame it, send it.
421 read = local.read(&mut buffer) => match read {
422 Ok(0) => {
423 // The local side finished sending, so this side ends the tunnel: its end
424 // of stream, then a close frame. A WebSocket close is a whole close, so a
425 // guest answer still in flight isn't relayed.
426 end_first(&mut socket, &mut noise, &mut scratch).await;
427 return Ok(TunnelEnd::Closed);
428 }
429 Ok(count) => {
430 let frame = match seal(&mut noise, &buffer[..count], &mut scratch)? {
431 Some(sealed) => sealed,
432 None => buffer[..count].to_vec(),
433 };
434 if socket.send(Message::Binary(frame.into())).await.is_err() {
435 return Ok(unproven_end(noise.as_ref(), None));
436 }
437 }
438 Err(err) => {
439 return Err(Error::new(
440 ErrorKind::Unexpected,
441 format!("reading the local connection failed: {err}"),
442 ));
443 }
444 },
445 // Read a frame from the tunnel, write it to the local client.
446 frame = socket.next() => match frame {
447 Some(Ok(Message::Binary(bytes))) => {
448 let plain: &[u8] = match noise.as_mut() {
449 None => &bytes,
450 Some(verified) => match open_frame(verified, &bytes, &mut scratch)? {
451 Frame::Chunk(count) => &scratch[..count],
452 // The daemon's end of stream: the guest reached EOF and every byte
453 // it sent came before this, in order, or this wouldn't open.
454 Frame::End => {
455 let _ = local.flush().await;
456 let _ = socket.send(Message::Close(None)).await;
457 return Ok(TunnelEnd::Closed);
458 }
459 },
460 };
461 if local.write_all(plain).await.is_err() {
462 // The local client went away, which ends the tunnel from this side.
463 end_first(&mut socket, &mut noise, &mut scratch).await;
464 return Ok(TunnelEnd::Closed);
465 }
466 }
467 // Text is not produced by the daemon. On a plain tunnel a caller that got it
468 // meant those bytes; on a verified one it cannot be a Noise message, so it is
469 // refused for the same reason a bad frame is.
470 Some(Ok(Message::Text(text))) => {
471 if noise.is_some() {
472 return Err(Error::new(
473 ErrorKind::Unexpected,
474 "a verified tunnel received a text frame, which cannot be a Noise \
475 message".to_string(),
476 ));
477 }
478 if local.write_all(text.as_bytes()).await.is_err() {
479 return Ok(TunnelEnd::Closed);
480 }
481 }
482 Some(Ok(Message::Close(frame))) => {
483 let _ = local.flush().await;
484 return Ok(match classify_close(frame.as_ref()) {
485 // A failure code is a failure however it arrived, so it keeps its code
486 // for the diagnosis. A clean-looking close is only as clean as the
487 // tunnel can prove.
488 TunnelEnd::Closed => {
489 unproven_end(noise.as_ref(), frame.as_ref().map(|f| f.code.into()))
490 }
491 other => other,
492 });
493 }
494 Some(Ok(_)) => continue,
495 // A transport error or a hangup after a successful handshake. Not an Err,
496 // because the bytes already delivered are still valid; on a plain tunnel the
497 // caller's own read of `local` is where a truncation would surface.
498 Some(Err(_)) | None => return Ok(unproven_end(noise.as_ref(), None)),
499 },
500 }
501 }
502}
503
504/// How a tunnel ends when the daemon's side ended with nothing that proves it: a close frame
505/// with no failure code, a transport error, or a hangup.
506///
507/// A plain tunnel has nothing to prove anything with, so that's [`TunnelEnd::Closed`], as it
508/// always was. A verified one is [`TunnelEnd::Truncated`] when the daemon offered the end of
509/// stream and [`TunnelEnd::ClosedUnproven`] when it predates one (BIND-23).
510fn unproven_end(noise: Option<&Verified>, code: Option<u16>) -> TunnelEnd {
511 match noise {
512 None => TunnelEnd::Closed,
513 Some(verified) if verified.daemon_proves_end => TunnelEnd::Truncated { code },
514 Some(_) => TunnelEnd::ClosedUnproven,
515 }
516}
517
518/// Ends the tunnel from this side: the end of stream on a verified tunnel, then a close frame.
519///
520/// Sent whatever the daemon offered: a daemon from before the end of stream decrypts it and
521/// relays nothing. Best effort, because the tunnel is ending either way; a daemon that never
522/// gets it resets its guest connection rather than closing it.
523async fn end_first(socket: &mut TunnelSocket, noise: &mut Option<Verified>, scratch: &mut [u8]) {
524 if let Ok(Some(end)) = seal(noise, &[], scratch) {
525 let _ = socket.send(Message::Binary(end.into())).await;
526 }
527 let _ = socket.send(Message::Close(None)).await;
528}
529
530/// Encrypts one chunk when the tunnel is verified, or reports `None` to send plaintext.
531///
532/// A helper so the send path above stays one expression per arm; the error is a local
533/// encryption failure, which is unreachable for chunk sizes the `const` checks admit but is
534/// reported honestly rather than unwrapped.
535fn seal(
536 noise: &mut Option<Verified>,
537 plain: &[u8],
538 scratch: &mut [u8],
539) -> Result<Option<Vec<u8>>, Error> {
540 let Some(verified) = noise.as_mut() else {
541 return Ok(None);
542 };
543 let count = verified
544 .transport
545 .write_message(plain, scratch)
546 .map_err(|err| {
547 Error::new(
548 ErrorKind::Unexpected,
549 format!("encrypting a tunnel chunk failed: {err}"),
550 )
551 })?;
552 Ok(Some(scratch[..count].to_vec()))
553}
554
555/// How the identity handshake ended: a keyed transport, or the daemon's refusal.
556///
557/// A transport exists only once the daemon's reply verifies against the pinned VM key, so a
558/// far end holding any other key never gets a byte of the local connection (BIND-21).
559enum Initiated {
560 Transport(Verified),
561 Refused(TunnelEnd),
562}
563
564/// Runs the initiator's half of the Noise KK handshake over the open WebSocket.
565async fn initiate(
566 socket: &mut TunnelSocket,
567 identity: µvms_app::identity::TunnelIdentity,
568 guest_port: u16,
569) -> Result<Initiated, Error> {
570 let mut initiator = crate::identity::initiator(identity)?;
571 let mut scratch = vec![0_u8; NOISE_MAX_MESSAGE_BYTES];
572
573 let written = initiator
574 .write_message(&protocol::identity::HANDSHAKE_PAYLOAD, &mut scratch)
575 .map_err(|err| {
576 Error::new(
577 ErrorKind::Unexpected,
578 format!("writing the identity handshake failed: {err}"),
579 )
580 })?;
581 if socket
582 .send(Message::Binary(scratch[..written].to_vec().into()))
583 .await
584 .is_err()
585 {
586 return Err(Error::new(
587 ErrorKind::Unexpected,
588 "the tunnel closed before the identity handshake could be sent".to_string(),
589 ));
590 }
591
592 // The daemon answers with its handshake message, or refuses with a close frame whose
593 // code says why (4401 no seed at launch, 4403 refused). Anything else mid-handshake is a
594 // transport fault.
595 loop {
596 match socket.next().await {
597 Some(Ok(Message::Binary(reply))) => {
598 return read_reply(initiator, &reply, &mut scratch, guest_port)
599 .map(Initiated::Transport);
600 }
601 Some(Ok(Message::Close(frame))) => {
602 return Ok(Initiated::Refused(classify_close(frame.as_ref())));
603 }
604 Some(Ok(_)) => continue,
605 Some(Err(err)) => {
606 return Err(Error::new(
607 ErrorKind::Unexpected,
608 format!("the tunnel failed mid-handshake: {err}"),
609 ));
610 }
611 None => {
612 return Err(Error::new(
613 ErrorKind::Unexpected,
614 "the tunnel closed mid-handshake with no close frame".to_string(),
615 ));
616 }
617 }
618 }
619}
620
621/// Reads the daemon's handshake reply into the session it keys (BIND-21).
622///
623/// Apart from the socket so that `tunnel_fuzz` can hand it any bytes. A reply that doesn't
624/// verify is the wrong-VM case as this side sees it, and the error names the pin.
625pub(crate) fn read_reply(
626 mut initiator: snow::HandshakeState,
627 reply: &[u8],
628 scratch: &mut [u8],
629 guest_port: u16,
630) -> Result<Verified, Error> {
631 let offer = initiator.read_message(reply, scratch).map_err(|_| {
632 // *Our* verification failed on the daemon's reply: the far end is not the VM the pin
633 // was minted for. This is the diagnosis the daemon cannot make, since it does not know
634 // which key we pinned, and the one the caller most needs, because the likely cause is
635 // a record replayed from another VM.
636 Error::new(
637 ErrorKind::Unexpected,
638 format!(
639 "the identity handshake reply did not verify against the pinned key for this \
640 VM. The far end holds a different seed than the one this record was created \
641 with: a record copied from another VM, or a VM relaunched with a fresh seed, \
642 would both do this. (guest port {guest_port})"
643 ),
644 )
645 })?;
646 // An empty payload is a daemon from before the end of stream (#342).
647 let daemon_proves_end = protocol::identity::offers_end_of_stream(&scratch[..offer]);
648 let transport = initiator.into_transport_mode().map_err(|err| {
649 Error::new(
650 ErrorKind::Unexpected,
651 format!("entering transport mode failed: {err}"),
652 )
653 })?;
654 Ok(Verified {
655 transport,
656 daemon_proves_end,
657 })
658}
659
660/// What one frame from the daemon opened to.
661#[derive(Debug, Eq, PartialEq)]
662pub(crate) enum Frame {
663 /// A chunk of this many bytes, at the front of the scratch buffer.
664 Chunk(usize),
665 /// The daemon's end of stream.
666 End,
667}
668
669/// Opens one frame the daemon sent after the handshake.
670///
671/// Apart from the socket so that `tunnel_fuzz` can hand it any bytes on any path.
672pub(crate) fn open_frame(
673 verified: &mut Verified,
674 frame: &[u8],
675 scratch: &mut [u8],
676) -> Result<Frame, Error> {
677 let count = verified
678 .transport
679 .read_message(frame, scratch)
680 .map_err(|err| {
681 // Failing rather than skipping, because a frame that does not authenticate on a
682 // verified tunnel is a forged, replayed or reordered frame (the nonce is its position),
683 // and writing it to the local client would hand the application attacker-controlled
684 // bytes on the one path that promised otherwise (BIND-24).
685 Error::new(
686 ErrorKind::Unexpected,
687 format!("a tunnel frame did not authenticate: {err}"),
688 )
689 })?;
690 Ok(if protocol::identity::is_end_of_stream(&scratch[..count]) {
691 Frame::End
692 } else {
693 Frame::Chunk(count)
694 })
695}
696
697/// Reads a close frame into a [`TunnelEnd`].
698///
699/// A missing frame is [`TunnelEnd::Closed`]: an absent code is not a failure, and treating
700/// it as one would turn every clean hangup into an error.
701fn classify_close(frame: Option<&CloseFrame>) -> TunnelEnd {
702 let Some(frame) = frame else {
703 return TunnelEnd::Closed;
704 };
705 let code: u16 = frame.code.into();
706 if code == protocol::tunnel::close::NORMAL {
707 return TunnelEnd::Closed;
708 }
709 TunnelEnd::Refused {
710 code,
711 reason: frame.reason.to_string(),
712 }
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718
719 #[test]
720 fn a_bare_endpoint_host_becomes_wss() {
721 // The same rule the HTTP backend applies to https: a missing prefix must not
722 // downgrade a bearer credential onto a plaintext socket.
723 assert_eq!(
724 tunnel_url("vm-abc.example.aws", 8080),
725 "wss://vm-abc.example.aws/v1/tcp?port=8080"
726 );
727 assert_eq!(
728 tunnel_url("https://vm-abc.example.aws/", 5432),
729 "wss://vm-abc.example.aws/v1/tcp?port=5432"
730 );
731 }
732
733 #[test]
734 fn an_explicit_plaintext_endpoint_stays_plaintext() {
735 // The local-daemon case a test uses. Explicit `http://` is a caller saying they know
736 // there is no TLS here; a bare hostname is not.
737 assert_eq!(
738 tunnel_url("http://127.0.0.1:9000", 8080),
739 "ws://127.0.0.1:9000/v1/tcp?port=8080"
740 );
741 assert_eq!(
742 tunnel_url("ws://127.0.0.1:9000", 1),
743 "ws://127.0.0.1:9000/v1/tcp?port=1"
744 );
745 }
746
747 #[test]
748 fn the_daemons_close_codes_are_explained_and_the_platforms_ambiguity_is_named() {
749 // 4502 is the daemon's, so the wording comes from the protocol crate — one definition
750 // for both sides.
751 let dead = explain_close(protocol::tunnel::close::NO_LISTENER, 5432)
752 .expect("4502 explains itself");
753 assert!(dead.contains("5432"), "{dead}");
754 assert!(dead.contains("not an auth problem"), "{dead}");
755
756 // 1006 is the platform's, and its explanation must present the ambiguity rather than
757 // pick one cause.
758 let opaque = explain_close(1006, 8080).expect("1006 is worth naming");
759 assert!(
760 opaque.contains("scope does not cover port 8080"),
761 "{opaque}"
762 );
763 assert!(opaque.contains("403"), "{opaque}");
764 assert!(opaque.contains("502"), "{opaque}");
765
766 // A code nobody in this system originates gets no invented meaning.
767 assert!(explain_close(4999, 8080).is_none());
768 }
769
770 #[test]
771 fn a_clean_close_is_not_a_refusal() {
772 assert_eq!(classify_close(None), TunnelEnd::Closed);
773 let normal = CloseFrame {
774 code: protocol::tunnel::close::NORMAL.into(),
775 reason: "done".into(),
776 };
777 assert_eq!(classify_close(Some(&normal)), TunnelEnd::Closed);
778 }
779
780 #[test]
781 fn a_daemon_refusal_carries_its_code_so_a_caller_can_branch() {
782 // The reason a code rather than a rendered string crosses this boundary: a dead
783 // server is worth retrying against another port and a relay failure is not.
784 let refused = CloseFrame {
785 code: protocol::tunnel::close::NO_LISTENER.into(),
786 reason: "nothing is listening on 127.0.0.1:5432 in the guest".into(),
787 };
788 match classify_close(Some(&refused)) {
789 TunnelEnd::Refused { code, reason } => {
790 assert_eq!(code, protocol::tunnel::close::NO_LISTENER);
791 assert!(reason.contains("5432"), "{reason}");
792 }
793 other => panic!("expected a refusal, got {other:?}"),
794 }
795 }
796
797 /// The marker this client offers is the one the daemon echoes.
798 ///
799 /// Not an assertion that two constants agree — `proxy::WS_SUBPROTOCOL` *is*
800 /// `protocol::tunnel::WS_MARKER_SUBPROTOCOL`, aliased rather than copied, so a drift is
801 /// unrepresentable rather than merely detected. This test pins the aliasing itself: an
802 /// edit that replaced the alias with a literal would pass every other test in the tree,
803 /// and the failure it caused would be a refused handshake naming neither side.
804 #[test]
805 fn the_offered_marker_is_the_daemons_echoed_marker() {
806 assert_eq!(
807 microvms_app::session::proxy::WS_SUBPROTOCOL,
808 protocol::tunnel::WS_MARKER_SUBPROTOCOL,
809 "the marker must stay aliased to the protocol crate's, not copied from it"
810 );
811 }
812
813 /// The two chunk sizes agree, as a compile error rather than a test failure.
814 ///
815 /// If the client framed larger than the daemon reads, one side would be the one that
816 /// fragments every stream — and which side fragments is exactly the kind of asymmetry
817 /// that shows up as a protocol bug in somebody else's application.
818 const _: () = {
819 assert!(
820 TUNNEL_CHUNK_BYTES == 64 * 1024,
821 "the client chunk must match the daemon's RELAY_CHUNK_BYTES"
822 );
823 };
824}