Skip to main content

microvms_protocol/
tunnel.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The TCP relay's wire types and close codes.
3//!
4//! Here rather than in `agentd` for ARCH-2's reason: the daemon serves this route and the
5//! client drives it, so the shape belongs to the crate both compile against. A close code
6//! spelled independently on each side is a code that can drift, and the drift would surface
7//! as a tunnel that reports "nothing listening" for a relay failure.
8//!
9//! The transport this describes rests on a measured platform property: the endpoint proxy
10//! carries **binary** WebSocket frames on a **port-scoped** token, byte-exact in both
11//! directions (`docs/PLATFORM.md`, 2026-08-29). There is no CONNECT method on the endpoint,
12//! which is why arbitrary TCP has to ride inside frames at all.
13
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17/// `?port=<n>[&identity=true]` — the guest port to relay to, and whether to prove identity.
18#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
19pub struct TunnelQuery {
20    /// The guest port. Dialled on `127.0.0.1` only.
21    ///
22    /// `0` parses and is refused *after* the upgrade with [`close::BAD_PORT`], rather than
23    /// rejected as a parse error: a caller who named it gets a reason naming the port
24    /// instead of a 400 that could equally mean a missing parameter.
25    pub port: u16,
26    /// Whether to run a Noise KK handshake before relaying any bytes.
27    ///
28    /// On this struct rather than in a second query type because one route has one query
29    /// contract, and the schema is generated from exactly that: a separate type would need a
30    /// second extractor and would not appear in `docs/schema.json` at all.
31    ///
32    /// Absent means `false`, which is what keeps every layer-2 client working unchanged. True
33    /// against a VM launched without a seed is refused with [`close::NO_IDENTITY`] rather than
34    /// silently downgraded — a caller who asked to verify identity and got an unverified
35    /// tunnel would believe a proof it never received. See [`super::identity`] for what the
36    /// handshake proves and its honest limit.
37    #[serde(default)]
38    pub identity: bool,
39}
40
41/// The bare marker subprotocol a MicroVM WebSocket handshake offers first.
42///
43/// Spelled here as well as in `microvms-core`'s proxy module because both the daemon and the
44/// client need it and for different reasons: the client *offers* it, and the daemon *echoes*
45/// it on the direct path. The proxied path never shows it to the daemon at all — the proxy
46/// consumes all three values and supplies this one to the client itself (measured,
47/// `docs/PLATFORM.md`). One definition means the echo cannot disagree with the offer, which
48/// would surface as a handshake refused for a reason naming neither side.
49pub const WS_MARKER_SUBPROTOCOL: &str = "lambda-microvms";
50
51/// The close codes the relay sends, and the only close codes in this system that mean
52/// anything.
53///
54/// Every WebSocket failure the *platform* produces is 1006 with no reason string (measured),
55/// so a caller cannot learn anything from a close it did not originate. These are drawn from
56/// RFC 6455's 4000–4999 private range precisely so they cannot be confused with one.
57pub mod close {
58    /// Normal completion: one side finished and the relay drained. The tunnel worked.
59    ///
60    /// Spelled here beside the failures so a client's match is exhaustive over one list.
61    pub const NORMAL: u16 = 1000;
62    /// The guest port refused the connection, or did not accept in time. Nothing is
63    /// listening — the credential and the scope were fine.
64    pub const NO_LISTENER: u16 = 4502;
65    /// `?port=0`, which cannot be dialled.
66    pub const BAD_PORT: u16 = 4400;
67    /// A read on an established guest connection failed mid-relay.
68    pub const RELAY_FAILED: u16 = 4500;
69    /// `?identity=1` against a VM launched without an identity seed.
70    ///
71    /// Its own code rather than a generic refusal, and it is the difference between a
72    /// diagnosable mistake and a mystery: the fix is at *launch* time, in a VM that no longer
73    /// exists by the time anyone reads this. A downgrade to an unverified tunnel would be
74    /// worse than either — the caller asked for proof and would believe one it never got.
75    pub const NO_IDENTITY: u16 = 4401;
76    /// The Noise handshake or session confirmation failed.
77    ///
78    /// Deliberately one code for all three. Under `KK` both static keys are mixed into the
79    /// handshake hash, so the daemon cannot distinguish "the caller pinned the wrong VM" from
80    /// "the caller is not the launching host" — both arrive as a decryption failure, and a
81    /// code that claimed to tell them apart would be guessing. The client knows which key it
82    /// offered and can say more; the daemon cannot.
83    pub const IDENTITY_REFUSED: u16 = 4403;
84
85    /// A human-readable explanation for a close code this relay may have sent.
86    ///
87    /// `None` for a code the relay does not originate — notably 1006, which is what every
88    /// platform-side failure collapses to and therefore explains nothing on its own. A
89    /// client that printed a guess for 1006 would be inventing a diagnosis.
90    pub fn explanation(code: u16, port: u16) -> Option<String> {
91        match code {
92            NORMAL => Some("the tunnel closed cleanly".to_string()),
93            NO_LISTENER => Some(format!(
94                "nothing is listening on 127.0.0.1:{port} inside the guest. The credential \
95                 and its port scope were accepted — this is a dead server, not an auth \
96                 problem."
97            )),
98            BAD_PORT => Some("port 0 cannot be dialled; name the guest port".to_string()),
99            RELAY_FAILED => Some(format!(
100                "the connection to 127.0.0.1:{port} failed after it had been established"
101            )),
102            NO_IDENTITY => Some(
103                "this VM was launched without an identity seed, so there is no key to prove \
104                 anything with. The seed is delivered in the run-hook payload at launch and \
105                 cannot be added to a running VM — relaunch with identity enabled, or drop \
106                 --verify-identity to use an unverified tunnel."
107                    .to_string(),
108            ),
109            IDENTITY_REFUSED => Some(
110                "the identity handshake failed. Either the pinned key is not this VM's — a \
111                 record copied from another launch would do that — or the caller does not hold \
112                 the launching host's private key. The daemon cannot tell those apart: both \
113                 static keys are mixed into the handshake hash, so both arrive as one \
114                 decryption failure. The protocol version, route, and requested guest port \
115                 must also agree, and the caller must confirm the resulting session."
116                    .to_string(),
117            ),
118            _ => None,
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn every_failure_code_is_in_the_rfc_private_range_and_distinct() {
129        // 4000-4999 is RFC 6455's application range, which is what keeps these from
130        // colliding with the platform's 1006.
131        let failures = [
132            close::NO_LISTENER,
133            close::BAD_PORT,
134            close::RELAY_FAILED,
135            close::NO_IDENTITY,
136            close::IDENTITY_REFUSED,
137        ];
138        for code in failures {
139            assert!(
140                (4000..5000).contains(&code),
141                "{code} is outside the private range and could collide with a platform code"
142            );
143        }
144        let mut seen = std::collections::BTreeSet::new();
145        for code in failures {
146            assert!(
147                seen.insert(code),
148                "{code} is used for two different outcomes"
149            );
150        }
151        assert!(!failures.contains(&close::NORMAL), "1000 is not a failure");
152    }
153
154    #[test]
155    fn a_platform_close_is_not_explained() {
156        // 1006 is what a refused handshake, a wrong-scope token, and a dead TCP connection
157        // all collapse to. Explaining it would be a guess presented as a diagnosis.
158        assert!(close::explanation(1006, 8080).is_none());
159        assert!(close::explanation(4999, 8080).is_none());
160    }
161
162    /// The two identity refusals are told apart, and each names where the fix is.
163    ///
164    /// They are the pair most likely to be confused, because both read as "identity did not
165    /// work". 4401 is fixable only at launch, in a VM that no longer exists by the time anyone
166    /// reads the message; 4403 is a wrong pin or a wrong caller against a VM that is fine. A
167    /// reader sent to the wrong one relaunches a healthy VM or debugs a key that was never
168    /// delivered.
169    #[test]
170    fn the_identity_refusals_point_at_different_fixes() {
171        let missing = close::explanation(close::NO_IDENTITY, 5432).expect("4401 explains itself");
172        assert!(
173            missing.contains("relaunch"),
174            "the fix for a missing seed is at launch time: {missing}"
175        );
176        assert!(missing.contains("run-hook payload"), "{missing}");
177
178        let refused =
179            close::explanation(close::IDENTITY_REFUSED, 5432).expect("4403 explains itself");
180        assert!(
181            refused.contains("pinned key"),
182            "a failed handshake is about the key, not the launch: {refused}"
183        );
184        // The daemon genuinely cannot distinguish the two causes, and the message must say so
185        // rather than pick one and sound certain.
186        assert!(refused.contains("cannot tell those apart"), "{refused}");
187        assert_ne!(missing, refused);
188    }
189
190    #[test]
191    fn the_dead_server_explanation_names_the_port_and_clears_the_credential() {
192        let text = close::explanation(close::NO_LISTENER, 5432).expect("4502 explains itself");
193        assert!(text.contains("5432"), "{text}");
194        assert!(text.contains("not an auth problem"), "{text}");
195    }
196}