shell_tunnel/relay/protocol.rs
1//! Wire protocol between a device and a relay.
2//!
3//! Messages are JSON text frames over one long-lived WebSocket ("control"
4//! connection). The device always dials *out*, which is what makes a machine
5//! behind NAT reachable without any inbound firewall change.
6//!
7//! Scope discipline: the relay decides only whether a device may attach and
8//! where its traffic goes. It never inspects or stores the `Authorization`
9//! header of proxied requests — capability tokens stay end-to-end between the
10//! client and the device, so attaching a relay does not widen the trust
11//! boundary.
12
13use serde::{Deserialize, Serialize};
14
15/// Protocol version. Bumped only on a breaking change to these messages.
16pub const PROTOCOL_VERSION: u32 = 1;
17
18/// Device → relay.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum DeviceMessage {
22 /// First message: ask to attach to this relay.
23 Enroll {
24 /// Shared secret proving the device may attach.
25 enroll_token: String,
26 /// Protocol version the device speaks.
27 version: u32,
28 /// Free-form label to make the device recognisable in relay logs.
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 label: Option<String>,
31 /// Requested stable identifier, becoming this device's routing key.
32 ///
33 /// Without it the relay assigns a random id, which changes on every
34 /// reconnect — fine when whoever calls the device can read its console,
35 /// useless when they cannot. A name keeps one URL valid across restarts.
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 device_name: Option<String>,
38 },
39 /// First frame on a *data* connection: claim a slot in a device's pool.
40 ///
41 /// The secret travels in the frame body, never in the URL. Query strings are
42 /// written to access logs by default by the reverse proxies this relay is
43 /// meant to sit behind, so a token in the URL would end up on disk in
44 /// plaintext on every deployment that follows our own TLS advice.
45 Attach {
46 /// Which device's pool this connection joins.
47 device_id: String,
48 /// Same secret the control channel presented.
49 enroll_token: String,
50 },
51 /// Liveness heartbeat.
52 ///
53 /// Sent by the device rather than relied upon from the transport: a proxy
54 /// or load balancer between the two closes idle connections (60s is the
55 /// common default), and tungstenite answers pings but never originates
56 /// them.
57 Heartbeat,
58}
59
60/// Relay → device.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(tag = "type", rename_all = "snake_case")]
63pub enum RelayMessage {
64 /// Enrollment accepted.
65 Enrolled {
66 /// Identifier the relay assigned. Relay-generated, never device-chosen,
67 /// so one device cannot claim or guess another's address.
68 device_id: String,
69 /// Public URL prefix that now routes to this device.
70 public_url: String,
71 },
72 /// Enrollment refused; the connection closes afterwards.
73 Rejected {
74 /// Machine-readable reason (`bad-token`, `unsupported-version`).
75 code: String,
76 /// Human-readable detail.
77 message: String,
78 },
79 /// Heartbeat acknowledgement.
80 HeartbeatAck,
81 /// Open `count` more data connections.
82 ///
83 /// Sent when the pool is short: at enrollment (to fill it) and whenever a
84 /// request consumes a connection. The device dials out for each one, so the
85 /// relay can ask but never initiate.
86 OpenData {
87 /// How many connections to open.
88 count: usize,
89 },
90}
91
92/// Reason codes used in [`RelayMessage::Rejected`].
93pub mod reject {
94 /// The enrollment token did not match.
95 pub const BAD_TOKEN: &str = "bad-token";
96 /// The device speaks a protocol version this relay does not support.
97 pub const UNSUPPORTED_VERSION: &str = "unsupported-version";
98 /// The first frame was not a valid enrollment.
99 pub const BAD_HANDSHAKE: &str = "bad-handshake";
100 /// The requested device name cannot be used as a routing key.
101 pub const BAD_DEVICE_NAME: &str = "bad-device-name";
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107
108 #[test]
109 fn enroll_roundtrips() {
110 let msg = DeviceMessage::Enroll {
111 enroll_token: "secret".into(),
112 version: PROTOCOL_VERSION,
113 label: Some("build-box".into()),
114 device_name: None,
115 };
116 let json = serde_json::to_string(&msg).unwrap();
117 assert!(json.contains("\"type\":\"enroll\""));
118 assert_eq!(serde_json::from_str::<DeviceMessage>(&json).unwrap(), msg);
119 }
120
121 #[test]
122 fn label_is_optional() {
123 let json = r#"{"type":"enroll","enroll_token":"s","version":1}"#;
124 let msg: DeviceMessage = serde_json::from_str(json).unwrap();
125 assert_eq!(
126 msg,
127 DeviceMessage::Enroll {
128 enroll_token: "s".into(),
129 version: 1,
130 label: None,
131 device_name: None,
132 }
133 );
134 }
135
136 #[test]
137 fn relay_messages_roundtrip() {
138 for msg in [
139 RelayMessage::Enrolled {
140 device_id: "d-1".into(),
141 public_url: "https://relay.example/d/d-1".into(),
142 },
143 RelayMessage::Rejected {
144 code: reject::BAD_TOKEN.into(),
145 message: "no".into(),
146 },
147 RelayMessage::HeartbeatAck,
148 RelayMessage::OpenData { count: 4 },
149 ] {
150 let json = serde_json::to_string(&msg).unwrap();
151 assert_eq!(serde_json::from_str::<RelayMessage>(&json).unwrap(), msg);
152 }
153 }
154
155 #[test]
156 fn a_requested_device_name_survives_the_wire() {
157 let msg = DeviceMessage::Enroll {
158 enroll_token: "s".into(),
159 version: PROTOCOL_VERSION,
160 label: None,
161 device_name: Some("build-box".into()),
162 };
163 let json = serde_json::to_string(&msg).unwrap();
164 assert_eq!(serde_json::from_str::<DeviceMessage>(&json).unwrap(), msg);
165 }
166
167 #[test]
168 fn attach_roundtrips() {
169 let msg = DeviceMessage::Attach {
170 device_id: "dev-1".into(),
171 enroll_token: "secret".into(),
172 };
173 let json = serde_json::to_string(&msg).unwrap();
174 assert!(json.contains("\"type\":\"attach\""));
175 assert_eq!(serde_json::from_str::<DeviceMessage>(&json).unwrap(), msg);
176 }
177
178 #[test]
179 fn unknown_message_types_are_rejected() {
180 // A future message type must not silently deserialize as something else.
181 assert!(serde_json::from_str::<DeviceMessage>(r#"{"type":"teleport"}"#).is_err());
182 }
183}