Skip to main content

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}