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/// Timeout applied to the first hello envelope read.
42pub const HELLO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
43
44// ---------------------------------------------------------------------------
45// HelloEnvelope
46// ---------------------------------------------------------------------------
47
48/// Identity block sent once in each direction when a WebSocket link comes up
49/// (RFC 017 §8). Version 2 is the first with a structured `identity` field;
50/// version 1 is implicit in pre-RFC-017 code.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct HelloEnvelope {
53    /// Wire tag discriminating this frame from other possible envelopes.
54    pub kind: HelloKind,
55    /// Version of the hello envelope schema. Must be >= 2.
56    pub version: u32,
57    /// The sender's identity block.
58    pub identity: PeerIdentity,
59}
60
61/// Tag identifying a [`HelloEnvelope`] on the wire.
62#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "snake_case")]
64pub enum HelloKind {
65    /// The sole variant — a hello envelope.
66    Hello,
67}
68
69/// Peer identity metadata advertised by a truffle node in its hello envelope.
70///
71/// The primary key for peer identity is `device_id`, which is stable across
72/// Tailscale re-auths and ephemeral-mode rotations. `tailscale_id` is an
73/// escape hatch kept so the session layer can correlate the hello with its
74/// Layer 3 peer entry — see the module-level note on the RFC §8 deviation.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76pub struct PeerIdentity {
77    /// The application namespace. Peers with different `app_id` close the
78    /// connection immediately (RFC §8, close code 4001).
79    pub app_id: String,
80    /// Stable per-device ULID.
81    pub device_id: String,
82    /// Human-readable device name (original form, NOT the slug).
83    pub device_name: String,
84    /// Operating system identifier (e.g., "darwin", "linux", "windows").
85    pub os: String,
86    /// Tailscale stable node ID — escape hatch used by the session layer to
87    /// cross-reference the hello with its Layer 3 peer registry.
88    #[serde(default)]
89    pub tailscale_id: String,
90}
91
92impl HelloEnvelope {
93    /// The hello envelope schema version this build emits.
94    pub const CURRENT_VERSION: u32 = 2;
95    /// The minimum hello envelope version this build will accept from a peer.
96    pub const MIN_SUPPORTED_VERSION: u32 = 2;
97
98    /// Construct a new hello envelope at [`Self::CURRENT_VERSION`] carrying
99    /// `identity`.
100    pub fn new(identity: PeerIdentity) -> Self {
101        Self {
102            kind: HelloKind::Hello,
103            version: Self::CURRENT_VERSION,
104            identity,
105        }
106    }
107}
108
109// ---------------------------------------------------------------------------
110// Tests
111// ---------------------------------------------------------------------------
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn hello_envelope_round_trips_json() {
119        let hello = HelloEnvelope::new(PeerIdentity {
120            app_id: "playground".into(),
121            device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".into(),
122            device_name: "Alice's MacBook".into(),
123            os: "darwin".into(),
124            tailscale_id: "node-abc".into(),
125        });
126
127        let json = serde_json::to_string(&hello).unwrap();
128        let parsed: HelloEnvelope = serde_json::from_str(&json).unwrap();
129        assert_eq!(parsed.version, HelloEnvelope::CURRENT_VERSION);
130        assert_eq!(parsed.identity.app_id, "playground");
131        assert_eq!(parsed.identity.device_id, "01J4K9M2Z8AB3RNYQPW6H5TC0X");
132        assert_eq!(parsed.identity.device_name, "Alice's MacBook");
133        assert_eq!(parsed.identity.os, "darwin");
134        assert_eq!(parsed.identity.tailscale_id, "node-abc");
135    }
136
137    #[test]
138    fn hello_envelope_json_layout_matches_rfc_shape() {
139        let hello = HelloEnvelope::new(PeerIdentity {
140            app_id: "chat".into(),
141            device_id: "01HZZZZZZZZZZZZZZZZZZZZZZZ".into(),
142            device_name: "laptop".into(),
143            os: "linux".into(),
144            tailscale_id: "tsnode-1".into(),
145        });
146        let value: serde_json::Value = serde_json::to_value(&hello).unwrap();
147        assert_eq!(value["kind"], "hello");
148        assert_eq!(value["version"], 2);
149        assert_eq!(value["identity"]["app_id"], "chat");
150        assert_eq!(value["identity"]["device_id"], "01HZZZZZZZZZZZZZZZZZZZZZZZ");
151        assert_eq!(value["identity"]["device_name"], "laptop");
152        assert_eq!(value["identity"]["os"], "linux");
153    }
154
155    #[test]
156    fn hello_envelope_rejects_unknown_kind() {
157        let raw = r#"{"kind":"not_hello","version":2,"identity":{"app_id":"a","device_id":"x","device_name":"y","os":"darwin","tailscale_id":"z"}}"#;
158        let parsed: Result<HelloEnvelope, _> = serde_json::from_str(raw);
159        assert!(
160            parsed.is_err(),
161            "unknown kind should fail to deserialize as HelloEnvelope"
162        );
163    }
164}