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    },
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}