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}