Skip to main content

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 = protocol::identity::TUNNEL_ROUTE;
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: &microvms_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: &microvms_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<&microvms_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<&microvms_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: &microvms_app::identity::TunnelIdentity,
568    guest_port: u16,
569) -> Result<Initiated, Error> {
570    let mut initiator = crate::identity::tunnel_initiator(identity, guest_port)?;
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                let mut verified = read_reply(initiator, &reply, &mut scratch, guest_port)?;
599                let written = verified
600                    .transport
601                    .write_message(protocol::identity::TUNNEL_CONFIRMATION, &mut scratch)
602                    .map_err(|error| {
603                        Error::new(
604                            ErrorKind::Unexpected,
605                            format!("confirming the tunnel session failed: {error}"),
606                        )
607                    })?;
608                socket
609                    .send(Message::Binary(scratch[..written].to_vec().into()))
610                    .await
611                    .map_err(|error| {
612                        Error::new(
613                            ErrorKind::Unexpected,
614                            format!("sending the tunnel confirmation failed: {error}"),
615                        )
616                    })?;
617                return Ok(Initiated::Transport(verified));
618            }
619            Some(Ok(Message::Close(frame))) => {
620                return Ok(Initiated::Refused(classify_close(frame.as_ref())));
621            }
622            Some(Ok(_)) => continue,
623            Some(Err(err)) => {
624                return Err(Error::new(
625                    ErrorKind::Unexpected,
626                    format!("the tunnel failed mid-handshake: {err}"),
627                ));
628            }
629            None => {
630                return Err(Error::new(
631                    ErrorKind::Unexpected,
632                    "the tunnel closed mid-handshake with no close frame".to_string(),
633                ));
634            }
635        }
636    }
637}
638
639/// Reads the daemon's handshake reply into the session it keys (BIND-21).
640///
641/// Apart from the socket so that `tunnel_fuzz` can hand it any bytes. A reply that doesn't
642/// verify is the wrong-VM case as this side sees it, and the error names the pin.
643pub(crate) fn read_reply(
644    mut initiator: snow::HandshakeState,
645    reply: &[u8],
646    scratch: &mut [u8],
647    guest_port: u16,
648) -> Result<Verified, Error> {
649    let offer = initiator.read_message(reply, scratch).map_err(|_| {
650        // Noise authenticates the pinned key and the expected context together, so a
651        // failed reply does not distinguish a key mismatch from a different context.
652        Error::new(
653            ErrorKind::Unexpected,
654            format!(
655                "the identity handshake reply did not verify against the pinned key and \
656                 expected tunnel context (protocol {}, route {}, guest port {guest_port})",
657                protocol::PROTOCOL_VERSION,
658                protocol::identity::TUNNEL_ROUTE
659            ),
660        )
661    })?;
662    // An empty payload is a daemon from before the end of stream (#342).
663    let daemon_proves_end = protocol::identity::offers_end_of_stream(&scratch[..offer]);
664    let transport = initiator.into_transport_mode().map_err(|err| {
665        Error::new(
666            ErrorKind::Unexpected,
667            format!("entering transport mode failed: {err}"),
668        )
669    })?;
670    Ok(Verified {
671        transport,
672        daemon_proves_end,
673    })
674}
675
676/// What one frame from the daemon opened to.
677#[derive(Debug, Eq, PartialEq)]
678pub(crate) enum Frame {
679    /// A chunk of this many bytes, at the front of the scratch buffer.
680    Chunk(usize),
681    /// The daemon's end of stream.
682    End,
683}
684
685/// Opens one frame the daemon sent after the handshake.
686///
687/// Apart from the socket so that `tunnel_fuzz` can hand it any bytes on any path.
688pub(crate) fn open_frame(
689    verified: &mut Verified,
690    frame: &[u8],
691    scratch: &mut [u8],
692) -> Result<Frame, Error> {
693    let count = verified
694        .transport
695        .read_message(frame, scratch)
696        .map_err(|err| {
697            // Failing rather than skipping, because a frame that does not authenticate on a
698            // verified tunnel is a forged, replayed or reordered frame (the nonce is its position),
699            // and writing it to the local client would hand the application attacker-controlled
700            // bytes on the one path that promised otherwise (BIND-24).
701            Error::new(
702                ErrorKind::Unexpected,
703                format!("a tunnel frame did not authenticate: {err}"),
704            )
705        })?;
706    Ok(if protocol::identity::is_end_of_stream(&scratch[..count]) {
707        Frame::End
708    } else {
709        Frame::Chunk(count)
710    })
711}
712
713/// Reads a close frame into a [`TunnelEnd`].
714///
715/// A missing frame is [`TunnelEnd::Closed`]: an absent code is not a failure, and treating
716/// it as one would turn every clean hangup into an error.
717fn classify_close(frame: Option<&CloseFrame>) -> TunnelEnd {
718    let Some(frame) = frame else {
719        return TunnelEnd::Closed;
720    };
721    let code: u16 = frame.code.into();
722    if code == protocol::tunnel::close::NORMAL {
723        return TunnelEnd::Closed;
724    }
725    TunnelEnd::Refused {
726        code,
727        reason: frame.reason.to_string(),
728    }
729}
730
731#[cfg(test)]
732mod tests {
733    use super::*;
734
735    #[test]
736    fn a_bare_endpoint_host_becomes_wss() {
737        // The same rule the HTTP backend applies to https: a missing prefix must not
738        // downgrade a bearer credential onto a plaintext socket.
739        assert_eq!(
740            tunnel_url("vm-abc.example.aws", 8080),
741            "wss://vm-abc.example.aws/v1/tcp?port=8080"
742        );
743        assert_eq!(
744            tunnel_url("https://vm-abc.example.aws/", 5432),
745            "wss://vm-abc.example.aws/v1/tcp?port=5432"
746        );
747    }
748
749    #[test]
750    fn an_explicit_plaintext_endpoint_stays_plaintext() {
751        // The local-daemon case a test uses. Explicit `http://` is a caller saying they know
752        // there is no TLS here; a bare hostname is not.
753        assert_eq!(
754            tunnel_url("http://127.0.0.1:9000", 8080),
755            "ws://127.0.0.1:9000/v1/tcp?port=8080"
756        );
757        assert_eq!(
758            tunnel_url("ws://127.0.0.1:9000", 1),
759            "ws://127.0.0.1:9000/v1/tcp?port=1"
760        );
761    }
762
763    #[test]
764    fn the_daemons_close_codes_are_explained_and_the_platforms_ambiguity_is_named() {
765        // 4502 is the daemon's, so the wording comes from the protocol crate — one definition
766        // for both sides.
767        let dead = explain_close(protocol::tunnel::close::NO_LISTENER, 5432)
768            .expect("4502 explains itself");
769        assert!(dead.contains("5432"), "{dead}");
770        assert!(dead.contains("not an auth problem"), "{dead}");
771
772        // 1006 is the platform's, and its explanation must present the ambiguity rather than
773        // pick one cause.
774        let opaque = explain_close(1006, 8080).expect("1006 is worth naming");
775        assert!(
776            opaque.contains("scope does not cover port 8080"),
777            "{opaque}"
778        );
779        assert!(opaque.contains("403"), "{opaque}");
780        assert!(opaque.contains("502"), "{opaque}");
781
782        // A code nobody in this system originates gets no invented meaning.
783        assert!(explain_close(4999, 8080).is_none());
784    }
785
786    #[test]
787    fn a_clean_close_is_not_a_refusal() {
788        assert_eq!(classify_close(None), TunnelEnd::Closed);
789        let normal = CloseFrame {
790            code: protocol::tunnel::close::NORMAL.into(),
791            reason: "done".into(),
792        };
793        assert_eq!(classify_close(Some(&normal)), TunnelEnd::Closed);
794    }
795
796    #[test]
797    fn a_daemon_refusal_carries_its_code_so_a_caller_can_branch() {
798        // The reason a code rather than a rendered string crosses this boundary: a dead
799        // server is worth retrying against another port and a relay failure is not.
800        let refused = CloseFrame {
801            code: protocol::tunnel::close::NO_LISTENER.into(),
802            reason: "nothing is listening on 127.0.0.1:5432 in the guest".into(),
803        };
804        match classify_close(Some(&refused)) {
805            TunnelEnd::Refused { code, reason } => {
806                assert_eq!(code, protocol::tunnel::close::NO_LISTENER);
807                assert!(reason.contains("5432"), "{reason}");
808            }
809            other => panic!("expected a refusal, got {other:?}"),
810        }
811    }
812
813    /// The marker this client offers is the one the daemon echoes.
814    ///
815    /// Not an assertion that two constants agree — `proxy::WS_SUBPROTOCOL` *is*
816    /// `protocol::tunnel::WS_MARKER_SUBPROTOCOL`, aliased rather than copied, so a drift is
817    /// unrepresentable rather than merely detected. This test pins the aliasing itself: an
818    /// edit that replaced the alias with a literal would pass every other test in the tree,
819    /// and the failure it caused would be a refused handshake naming neither side.
820    #[test]
821    fn the_offered_marker_is_the_daemons_echoed_marker() {
822        assert_eq!(
823            microvms_app::session::proxy::WS_SUBPROTOCOL,
824            protocol::tunnel::WS_MARKER_SUBPROTOCOL,
825            "the marker must stay aliased to the protocol crate's, not copied from it"
826        );
827    }
828
829    /// The two chunk sizes agree, as a compile error rather than a test failure.
830    ///
831    /// If the client framed larger than the daemon reads, one side would be the one that
832    /// fragments every stream — and which side fragments is exactly the kind of asymmetry
833    /// that shows up as a protocol bug in somebody else's application.
834    const _: () = {
835        assert!(
836            TUNNEL_CHUNK_BYTES == 64 * 1024,
837            "the client chunk must match the daemon's RELAY_CHUNK_BYTES"
838        );
839    };
840}