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}