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 failed: a wrong pin, a replayed record, or a corrupted stream.
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."
115 .to_string(),
116 ),
117 _ => None,
118 }
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn every_failure_code_is_in_the_rfc_private_range_and_distinct() {
128 // 4000-4999 is RFC 6455's application range, which is what keeps these from
129 // colliding with the platform's 1006.
130 let failures = [
131 close::NO_LISTENER,
132 close::BAD_PORT,
133 close::RELAY_FAILED,
134 close::NO_IDENTITY,
135 close::IDENTITY_REFUSED,
136 ];
137 for code in failures {
138 assert!(
139 (4000..5000).contains(&code),
140 "{code} is outside the private range and could collide with a platform code"
141 );
142 }
143 let mut seen = std::collections::BTreeSet::new();
144 for code in failures {
145 assert!(
146 seen.insert(code),
147 "{code} is used for two different outcomes"
148 );
149 }
150 assert!(!failures.contains(&close::NORMAL), "1000 is not a failure");
151 }
152
153 #[test]
154 fn a_platform_close_is_not_explained() {
155 // 1006 is what a refused handshake, a wrong-scope token, and a dead TCP connection
156 // all collapse to. Explaining it would be a guess presented as a diagnosis.
157 assert!(close::explanation(1006, 8080).is_none());
158 assert!(close::explanation(4999, 8080).is_none());
159 }
160
161 /// The two identity refusals are told apart, and each names where the fix is.
162 ///
163 /// They are the pair most likely to be confused, because both read as "identity did not
164 /// work". 4401 is fixable only at launch, in a VM that no longer exists by the time anyone
165 /// reads the message; 4403 is a wrong pin or a wrong caller against a VM that is fine. A
166 /// reader sent to the wrong one relaunches a healthy VM or debugs a key that was never
167 /// delivered.
168 #[test]
169 fn the_identity_refusals_point_at_different_fixes() {
170 let missing = close::explanation(close::NO_IDENTITY, 5432).expect("4401 explains itself");
171 assert!(
172 missing.contains("relaunch"),
173 "the fix for a missing seed is at launch time: {missing}"
174 );
175 assert!(missing.contains("run-hook payload"), "{missing}");
176
177 let refused =
178 close::explanation(close::IDENTITY_REFUSED, 5432).expect("4403 explains itself");
179 assert!(
180 refused.contains("pinned key"),
181 "a failed handshake is about the key, not the launch: {refused}"
182 );
183 // The daemon genuinely cannot distinguish the two causes, and the message must say so
184 // rather than pick one and sound certain.
185 assert!(refused.contains("cannot tell those apart"), "{refused}");
186 assert_ne!(missing, refused);
187 }
188
189 #[test]
190 fn the_dead_server_explanation_names_the_port_and_clears_the_credential() {
191 let text = close::explanation(close::NO_LISTENER, 5432).expect("4502 explains itself");
192 assert!(text.contains("5432"), "{text}");
193 assert!(text.contains("not an auth problem"), "{text}");
194 }
195}