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 },
32 /// First frame on a *data* connection: claim a slot in a device's pool.
33 ///
34 /// The secret travels in the frame body, never in the URL. Query strings are
35 /// written to access logs by default by the reverse proxies this relay is
36 /// meant to sit behind, so a token in the URL would end up on disk in
37 /// plaintext on every deployment that follows our own TLS advice.
38 Attach {
39 /// Which device's pool this connection joins.
40 device_id: String,
41 /// Same secret the control channel presented.
42 enroll_token: String,
43 },
44 /// Liveness heartbeat.
45 ///
46 /// Sent by the device rather than relied upon from the transport: a proxy
47 /// or load balancer between the two closes idle connections (60s is the
48 /// common default), and tungstenite answers pings but never originates
49 /// them.
50 Heartbeat,
51}
52
53/// Relay → device.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(tag = "type", rename_all = "snake_case")]
56pub enum RelayMessage {
57 /// Enrollment accepted.
58 Enrolled {
59 /// Identifier the relay assigned. Relay-generated, never device-chosen,
60 /// so one device cannot claim or guess another's address.
61 device_id: String,
62 /// Public URL prefix that now routes to this device.
63 public_url: String,
64 },
65 /// Enrollment refused; the connection closes afterwards.
66 Rejected {
67 /// Machine-readable reason (`bad-token`, `unsupported-version`).
68 code: String,
69 /// Human-readable detail.
70 message: String,
71 },
72 /// Heartbeat acknowledgement.
73 HeartbeatAck,
74 /// Open `count` more data connections.
75 ///
76 /// Sent when the pool is short: at enrollment (to fill it) and whenever a
77 /// request consumes a connection. The device dials out for each one, so the
78 /// relay can ask but never initiate.
79 OpenData {
80 /// How many connections to open.
81 count: usize,
82 },
83}
84
85/// Reason codes used in [`RelayMessage::Rejected`].
86pub mod reject {
87 /// The enrollment token did not match.
88 pub const BAD_TOKEN: &str = "bad-token";
89 /// The device speaks a protocol version this relay does not support.
90 pub const UNSUPPORTED_VERSION: &str = "unsupported-version";
91 /// The first frame was not a valid enrollment.
92 pub const BAD_HANDSHAKE: &str = "bad-handshake";
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn enroll_roundtrips() {
101 let msg = DeviceMessage::Enroll {
102 enroll_token: "secret".into(),
103 version: PROTOCOL_VERSION,
104 label: Some("build-box".into()),
105 };
106 let json = serde_json::to_string(&msg).unwrap();
107 assert!(json.contains("\"type\":\"enroll\""));
108 assert_eq!(serde_json::from_str::<DeviceMessage>(&json).unwrap(), msg);
109 }
110
111 #[test]
112 fn label_is_optional() {
113 let json = r#"{"type":"enroll","enroll_token":"s","version":1}"#;
114 let msg: DeviceMessage = serde_json::from_str(json).unwrap();
115 assert_eq!(
116 msg,
117 DeviceMessage::Enroll {
118 enroll_token: "s".into(),
119 version: 1,
120 label: None
121 }
122 );
123 }
124
125 #[test]
126 fn relay_messages_roundtrip() {
127 for msg in [
128 RelayMessage::Enrolled {
129 device_id: "d-1".into(),
130 public_url: "https://relay.example/d/d-1".into(),
131 },
132 RelayMessage::Rejected {
133 code: reject::BAD_TOKEN.into(),
134 message: "no".into(),
135 },
136 RelayMessage::HeartbeatAck,
137 RelayMessage::OpenData { count: 4 },
138 ] {
139 let json = serde_json::to_string(&msg).unwrap();
140 assert_eq!(serde_json::from_str::<RelayMessage>(&json).unwrap(), msg);
141 }
142 }
143
144 #[test]
145 fn attach_roundtrips() {
146 let msg = DeviceMessage::Attach {
147 device_id: "dev-1".into(),
148 enroll_token: "secret".into(),
149 };
150 let json = serde_json::to_string(&msg).unwrap();
151 assert!(json.contains("\"type\":\"attach\""));
152 assert_eq!(serde_json::from_str::<DeviceMessage>(&json).unwrap(), msg);
153 }
154
155 #[test]
156 fn unknown_message_types_are_rejected() {
157 // A future message type must not silently deserialize as something else.
158 assert!(serde_json::from_str::<DeviceMessage>(r#"{"type":"teleport"}"#).is_err());
159 }
160}