Skip to main content

truffle_core/session/
hello.rs

1//! Hello envelope — Layer 5 identity handshake (RFC 017 §8).
2//!
3//! When two truffle nodes establish a WebSocket link, each side sends a
4//! [`HelloEnvelope`] as the very first message over the socket and expects
5//! one back before any application traffic flows. The envelope advertises
6//! the sender's `app_id`, stable `device_id`, human-readable `device_name`,
7//! operating system, and (as an escape hatch) its Tailscale stable ID.
8//!
9//! # Version
10//!
11//! `HelloEnvelope::version` starts at `2`. Version 1 is implicit in
12//! pre-RFC-017 code and is explicitly rejected — RFC 017 is a clean break
13//! and older peers cannot talk to newer ones.
14//!
15//! # Deviation from RFC §8
16//!
17//! RFC 017 §8 lists four fields inside the `identity` block: `app_id`,
18//! `device_id`, `device_name`, `os`. This implementation adds a fifth
19//! field, `tailscale_id`, because the session layer still routes
20//! WebSocket connections by Tailscale stable ID (the primary key of the
21//! session's peer map). Carrying the remote's Tailscale ID in the hello
22//! lets the session correlate the freshly-accepted WS link with the
23//! Layer 3 peer entry that already exists. A future phase can remove
24//! this field once the session layer re-keys its connection map by
25//! `device_id`.
26
27use serde::{Deserialize, Serialize};
28
29// ---------------------------------------------------------------------------
30// WebSocket close codes (RFC 017 §8)
31// ---------------------------------------------------------------------------
32
33/// Remote peer's `app_id` did not match ours. The link is closed immediately
34/// with this code so the remote sees a specific reason.
35pub const CLOSE_APP_MISMATCH: u16 = 4001;
36
37/// Remote peer sent a malformed hello envelope, or no hello at all within the
38/// timeout.
39pub const CLOSE_HELLO_PROTOCOL: u16 = 4002;
40
41/// The hello envelope claimed a `tailscale_id` that does not match the
42/// Tailscale-authenticated identity (WhoIs) of the underlying connection.
43pub const CLOSE_IDENTITY_MISMATCH: u16 = 4003;
44
45/// Timeout applied to the first hello envelope read.
46pub const HELLO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
47
48// ---------------------------------------------------------------------------
49// HelloEnvelope
50// ---------------------------------------------------------------------------
51
52/// Identity block sent once in each direction when a WebSocket link comes up
53/// (RFC 017 §8). Version 2 is the first with a structured `identity` field;
54/// version 1 is implicit in pre-RFC-017 code.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct HelloEnvelope {
57    /// Wire tag discriminating this frame from other possible envelopes.
58    pub kind: HelloKind,
59    /// Version of the hello envelope schema. Must be >= 2.
60    pub version: u32,
61    /// The sender's identity block.
62    pub identity: PeerIdentity,
63}
64
65/// Tag identifying a [`HelloEnvelope`] on the wire.
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "snake_case")]
68pub enum HelloKind {
69    /// The sole variant — a hello envelope.
70    Hello,
71}
72
73/// Peer identity metadata advertised by a truffle node in its hello envelope.
74///
75/// The primary key for peer identity is `device_id`, which is stable across
76/// Tailscale re-auths and ephemeral-mode rotations. `tailscale_id` is an
77/// escape hatch kept so the session layer can correlate the hello with its
78/// Layer 3 peer entry — see the module-level note on the RFC §8 deviation.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct PeerIdentity {
81    /// The application namespace. Peers with different `app_id` close the
82    /// connection immediately (RFC §8, close code 4001).
83    pub app_id: String,
84    /// Stable per-device ULID.
85    pub device_id: String,
86    /// Human-readable device name (original form, NOT the slug).
87    pub device_name: String,
88    /// Operating system identifier (e.g., "darwin", "linux", "windows").
89    pub os: String,
90    /// Tailscale stable node ID — escape hatch used by the session layer to
91    /// cross-reference the hello with its Layer 3 peer registry.
92    #[serde(default)]
93    pub tailscale_id: String,
94}
95
96impl HelloEnvelope {
97    /// The hello envelope schema version this build emits.
98    pub const CURRENT_VERSION: u32 = 2;
99    /// The minimum hello envelope version this build will accept from a peer.
100    pub const MIN_SUPPORTED_VERSION: u32 = 2;
101
102    /// Construct a new hello envelope at [`Self::CURRENT_VERSION`] carrying
103    /// `identity`.
104    pub fn new(identity: PeerIdentity) -> Self {
105        Self {
106            kind: HelloKind::Hello,
107            version: Self::CURRENT_VERSION,
108            identity,
109        }
110    }
111}
112
113// ---------------------------------------------------------------------------
114// Tests
115// ---------------------------------------------------------------------------
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn hello_envelope_round_trips_json() {
123        let hello = HelloEnvelope::new(PeerIdentity {
124            app_id: "playground".into(),
125            device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
126            device_name: "Alice's MacBook".into(),
127            os: "darwin".into(),
128            tailscale_id: "node-abc".into(),
129        });
130
131        let json = serde_json::to_string(&hello).unwrap();
132        let parsed: HelloEnvelope = serde_json::from_str(&json).unwrap();
133        assert_eq!(parsed.version, HelloEnvelope::CURRENT_VERSION);
134        assert_eq!(parsed.identity.app_id, "playground");
135        assert_eq!(parsed.identity.device_id, "01J4K9M2Z8AB3RNYQPW6H5TC0X");
136        assert_eq!(parsed.identity.device_name, "Alice's MacBook");
137        assert_eq!(parsed.identity.os, "darwin");
138        assert_eq!(parsed.identity.tailscale_id, "node-abc");
139    }
140
141    #[test]
142    fn hello_envelope_json_layout_matches_rfc_shape() {
143        let hello = HelloEnvelope::new(PeerIdentity {
144            app_id: "chat".into(),
145            device_id: "01HZZZZZZZZZZZZZZZZZZZZZZZ".into(),
146            device_name: "laptop".into(),
147            os: "linux".into(),
148            tailscale_id: "tsnode-1".into(),
149        });
150        let value: serde_json::Value = serde_json::to_value(&hello).unwrap();
151        assert_eq!(value["kind"], "hello");
152        assert_eq!(value["version"], 2);
153        assert_eq!(value["identity"]["app_id"], "chat");
154        assert_eq!(value["identity"]["device_id"], "01HZZZZZZZZZZZZZZZZZZZZZZZ");
155        assert_eq!(value["identity"]["device_name"], "laptop");
156        assert_eq!(value["identity"]["os"], "linux");
157    }
158
159    #[test]
160    fn hello_envelope_rejects_unknown_kind() {
161        let raw = r#"{"kind":"not_hello","version":2,"identity":{"app_id":"a","device_id":"x","device_name":"y","os":"darwin","tailscale_id":"z"}}"#;
162        let parsed: Result<HelloEnvelope, _> = serde_json::from_str(raw);
163        assert!(
164            parsed.is_err(),
165            "unknown kind should fail to deserialize as HelloEnvelope"
166        );
167    }
168}