Skip to main content

truffle_core/transport/
websocket.rs

1//! WebSocket transport — [`StreamTransport`] implementation over WebSocket.
2//!
3//! Uses `tokio-tungstenite` for the WS protocol and delegates all raw TCP
4//! connectivity to Layer 3's [`NetworkProvider`].
5//!
6//! # Connection flow
7//!
8//! **Client (connect)**:
9//! 1. `NetworkProvider::dial_tcp(addr, port)` -> raw `TcpStream`
10//! 2. WebSocket client handshake (`tokio_tungstenite::client_async_with_config`)
11//! 3. Send a [`HelloEnvelope`] (RFC 017 §8) as a text frame (JSON)
12//! 4. Receive peer's [`HelloEnvelope`], validate `app_id` and version
13//! 5. Split stream into read/write halves, return [`WsFramedStream`]
14//!
15//! **Server (listen)**:
16//! 1. `NetworkProvider::listen_tcp(port)` -> incoming `TcpStream`s
17//! 2. For each: WebSocket server handshake (`tokio_tungstenite::accept_async_with_config`)
18//! 3. Receive peer's [`HelloEnvelope`], validate, send own [`HelloEnvelope`]
19//! 4. Split stream into read/write halves, yield [`WsFramedStream`] via [`StreamListener`]
20//!
21//! The `app_id` check happens during the hello exchange: a mismatch closes
22//! the socket with close code 4001 so the remote sees a specific reason. A
23//! missing or malformed hello closes with code 4002.
24//!
25//! # Heartbeat
26//!
27//! After the hello exchange, a background task sends WebSocket Ping frames
28//! at `WsConfig::ping_interval` on the write half. The read half updates a
29//! shared `last_pong` timestamp when it receives a Pong. The heartbeat task
30//! checks this timestamp on each ping cycle and closes the connection if
31//! `pong_timeout` has been exceeded.
32
33use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
34use std::sync::Arc;
35use std::time::Duration;
36
37use futures_util::stream::SplitSink;
38use futures_util::stream::SplitStream;
39use futures_util::{SinkExt, StreamExt};
40use tokio::net::TcpStream;
41use tokio::sync::Mutex;
42use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
43use tokio_tungstenite::tungstenite::protocol::frame::CloseFrame;
44use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
45use tokio_tungstenite::tungstenite::Message;
46use tokio_tungstenite::WebSocketStream;
47
48use crate::network::{NetworkProvider, PeerAddr};
49use crate::session::hello::{
50    HelloEnvelope, HelloKind, PeerIdentity, CLOSE_APP_MISMATCH, CLOSE_HELLO_PROTOCOL, HELLO_TIMEOUT,
51};
52
53use super::{
54    resolve_dial_addr, FramedStream, StreamListener, StreamTransport, TransportError, WsConfig,
55};
56
57// ---------------------------------------------------------------------------
58// WebSocketTransport
59// ---------------------------------------------------------------------------
60
61/// WebSocket-based [`StreamTransport`] implementation.
62///
63/// Generic over the [`NetworkProvider`] type `N`. Owns a shared reference
64/// to Layer 3 for establishing raw TCP connections, and a [`WsConfig`]
65/// for protocol parameters.
66///
67/// # Example
68///
69/// ```ignore
70/// use std::sync::Arc;
71/// use truffle_core::transport::{WsConfig, websocket::WebSocketTransport};
72///
73/// let ws = WebSocketTransport::new(Arc::new(provider), WsConfig::default());
74/// let stream = ws.connect(&peer_addr).await?;
75/// ```
76pub struct WebSocketTransport<N: NetworkProvider> {
77    /// Layer 3 network provider for raw TCP dial/listen.
78    network: Arc<N>,
79    /// WebSocket configuration.
80    config: WsConfig,
81}
82
83impl<N: NetworkProvider + 'static> WebSocketTransport<N> {
84    /// Create a new WebSocket transport.
85    ///
86    /// - `network`: An `Arc<N>` where `N: NetworkProvider`.
87    /// - `config`: WebSocket configuration (port, heartbeat intervals, etc.).
88    pub fn new(network: Arc<N>, config: WsConfig) -> Self {
89        Self { network, config }
90    }
91
92    /// Build the local hello envelope from the network provider's identity.
93    ///
94    /// RFC 017 Phase 2: the transport handshake is now a [`HelloEnvelope`]
95    /// carrying `app_id` + `device_id` + `device_name` + `os` + an escape
96    /// hatch `tailscale_id` used by the session layer to correlate the
97    /// link with its Layer 3 peer entry.
98    fn local_hello(&self) -> HelloEnvelope {
99        let identity = self.network.local_identity();
100        HelloEnvelope::new(PeerIdentity {
101            app_id: identity.app_id,
102            device_id: identity.device_id,
103            device_name: identity.device_name,
104            os: std::env::consts::OS.to_string(),
105            tailscale_id: identity.tailscale_id,
106        })
107    }
108
109    /// Build a tungstenite `WebSocketConfig` from our `WsConfig`.
110    fn ws_protocol_config(&self) -> WebSocketConfig {
111        let mut config = WebSocketConfig::default();
112        config.max_message_size = Some(self.config.max_message_size);
113        config.max_frame_size = Some(self.config.max_message_size);
114        config
115    }
116}
117
118/// Send a close frame to the remote and drop the stream.
119///
120/// Tries to deliver the close code so the peer can log a specific reason for
121/// the drop. Errors from the close path are swallowed — at this point the
122/// connection is already doomed and the caller will propagate a richer
123/// [`TransportError`].
124async fn close_ws_with_code(ws: &mut WebSocketStream<TcpStream>, code: u16, reason: &str) {
125    let close_frame = CloseFrame {
126        code: CloseCode::from(code),
127        reason: reason.to_string().into(),
128    };
129    let _ = ws.send(Message::Close(Some(close_frame))).await;
130    let _ = ws.close(None).await;
131}
132
133/// Maximum number of control frames (Ping/Pong/Frame) we tolerate from a
134/// peer before the first application-level hello frame arrives. A well-
135/// behaved client sends at most one Pong here; anything higher is either a
136/// broken client or an active DoS against the 5s hello window.
137const MAX_CONTROL_FRAMES_BEFORE_HELLO: usize = 16;
138
139/// Receive and parse a [`HelloEnvelope`] from a WebSocket stream, applying a
140/// 5 second timeout.
141///
142/// Consumes frames from the stream until the first application-level message
143/// (Text or Binary) arrives. Control frames (Ping, Pong, Frame) are handled
144/// transparently, but we cap the number tolerated before the hello so a
145/// malicious peer can't flood Pings for the full timeout window. Returns a
146/// specific `TransportError` for each failure mode so the caller can emit
147/// the right close code.
148async fn receive_hello(
149    ws: &mut WebSocketStream<TcpStream>,
150) -> Result<HelloEnvelope, TransportError> {
151    let fut = async {
152        let mut control_frame_count: usize = 0;
153        loop {
154            match ws.next().await {
155                Some(Ok(Message::Text(text))) => {
156                    return serde_json::from_str::<HelloEnvelope>(&text)
157                        .map_err(|e| TransportError::HelloMalformed(format!("parse hello: {e}")));
158                }
159                Some(Ok(Message::Binary(data))) => {
160                    return serde_json::from_slice::<HelloEnvelope>(&data)
161                        .map_err(|e| TransportError::HelloMalformed(format!("parse hello: {e}")));
162                }
163                Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => {
164                    control_frame_count += 1;
165                    if control_frame_count > MAX_CONTROL_FRAMES_BEFORE_HELLO {
166                        return Err(TransportError::HelloMalformed(
167                            "too many control frames before hello".to_string(),
168                        ));
169                    }
170                    continue;
171                }
172                Some(Ok(Message::Close(_))) => {
173                    return Err(TransportError::HelloMalformed(
174                        "peer closed connection before hello".to_string(),
175                    ));
176                }
177                Some(Err(e)) => {
178                    return Err(TransportError::HelloMalformed(format!(
179                        "receive hello: {e}"
180                    )));
181                }
182                None => {
183                    return Err(TransportError::HelloMalformed(
184                        "connection closed before hello".to_string(),
185                    ));
186                }
187            }
188        }
189    };
190
191    match tokio::time::timeout(HELLO_TIMEOUT, fut).await {
192        Ok(inner) => inner,
193        Err(_) => Err(TransportError::HelloTimeout),
194    }
195}
196
197// Maximum byte lengths for each string field in a received PeerIdentity.
198// Enforced BEFORE the app_id mismatch check so an oversized field can't
199// sneak past via a matched app_id. Sized ~2x the RFC 017 §5 soft caps.
200const MAX_APP_ID_LEN: usize = 32;
201const MAX_DEVICE_NAME_LEN: usize = 512;
202const MAX_DEVICE_ID_LEN: usize = 64;
203const MAX_TAILSCALE_ID_LEN: usize = 256;
204const MAX_OS_LEN: usize = 32;
205
206/// Validate a received hello against our own. Returns the remote identity
207/// on success, or a classified error that maps to an RFC 017 close code.
208fn validate_hello(
209    remote: HelloEnvelope,
210    local_app_id: &str,
211) -> Result<PeerIdentity, TransportError> {
212    if remote.kind != HelloKind::Hello {
213        return Err(TransportError::HelloMalformed(format!(
214            "unexpected hello kind: {:?}",
215            remote.kind
216        )));
217    }
218    if remote.version < HelloEnvelope::MIN_SUPPORTED_VERSION {
219        return Err(TransportError::HelloMalformed(format!(
220            "unsupported hello version {} (minimum supported: {})",
221            remote.version,
222            HelloEnvelope::MIN_SUPPORTED_VERSION
223        )));
224    }
225    // Length-bound each identity field BEFORE the app_id mismatch check
226    // so an oversized field can't sneak through on a matched app_id.
227    if remote.identity.app_id.len() > MAX_APP_ID_LEN
228        || remote.identity.device_name.len() > MAX_DEVICE_NAME_LEN
229        || remote.identity.device_id.len() > MAX_DEVICE_ID_LEN
230        || remote.identity.tailscale_id.len() > MAX_TAILSCALE_ID_LEN
231        || remote.identity.os.len() > MAX_OS_LEN
232    {
233        return Err(TransportError::HelloMalformed(
234            "identity field exceeds maximum allowed length".to_string(),
235        ));
236    }
237    if remote.identity.app_id != local_app_id {
238        return Err(TransportError::AppMismatch {
239            local: local_app_id.to_string(),
240            remote: remote.identity.app_id.clone(),
241        });
242    }
243    Ok(remote.identity)
244}
245
246/// Serialise and send a [`HelloEnvelope`] as a text frame.
247async fn send_hello(
248    ws: &mut WebSocketStream<TcpStream>,
249    hello: &HelloEnvelope,
250) -> Result<(), TransportError> {
251    let payload =
252        serde_json::to_string(hello).map_err(|e| TransportError::Serialize(e.to_string()))?;
253    ws.send(Message::Text(payload.into()))
254        .await
255        .map_err(|e| TransportError::HandshakeFailed(format!("send hello: {e}")))
256}
257
258/// Client-side hello exchange: send ours first, read theirs, validate, then
259/// return the remote identity. On error, close the socket with the matching
260/// RFC 017 close code before returning the error.
261async fn client_hello_exchange(
262    ws: &mut WebSocketStream<TcpStream>,
263    local_hello: &HelloEnvelope,
264) -> Result<PeerIdentity, TransportError> {
265    // Step 1: send our hello first — this is envelope zero.
266    if let Err(e) = send_hello(ws, local_hello).await {
267        close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "local send failed").await;
268        return Err(e);
269    }
270
271    // Step 2: read remote hello (timed).
272    let remote = match receive_hello(ws).await {
273        Ok(envelope) => envelope,
274        Err(e) => {
275            match &e {
276                TransportError::HelloTimeout => {
277                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "hello timeout").await;
278                }
279                TransportError::HelloMalformed(_) => {
280                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "malformed hello").await;
281                }
282                _ => {}
283            }
284            return Err(e);
285        }
286    };
287
288    // Step 3: validate against our expectations.
289    match validate_hello(remote, &local_hello.identity.app_id) {
290        Ok(identity) => Ok(identity),
291        Err(e) => {
292            match &e {
293                TransportError::AppMismatch { .. } => {
294                    close_ws_with_code(ws, CLOSE_APP_MISMATCH, "app mismatch").await;
295                }
296                TransportError::HelloMalformed(_) => {
297                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "bad hello").await;
298                }
299                _ => {}
300            }
301            Err(e)
302        }
303    }
304}
305
306/// Server-side hello exchange: read theirs first, validate, then send ours.
307/// On error, close the socket with the matching RFC 017 close code.
308async fn server_hello_exchange(
309    ws: &mut WebSocketStream<TcpStream>,
310    local_hello: &HelloEnvelope,
311) -> Result<PeerIdentity, TransportError> {
312    // Step 1: read remote hello (timed).
313    let remote = match receive_hello(ws).await {
314        Ok(envelope) => envelope,
315        Err(e) => {
316            match &e {
317                TransportError::HelloTimeout => {
318                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "hello timeout").await;
319                }
320                TransportError::HelloMalformed(_) => {
321                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "malformed hello").await;
322                }
323                _ => {}
324            }
325            return Err(e);
326        }
327    };
328
329    // Step 2: validate against our expectations.
330    let identity = match validate_hello(remote, &local_hello.identity.app_id) {
331        Ok(identity) => identity,
332        Err(e) => {
333            match &e {
334                TransportError::AppMismatch { .. } => {
335                    close_ws_with_code(ws, CLOSE_APP_MISMATCH, "app mismatch").await;
336                }
337                TransportError::HelloMalformed(_) => {
338                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "bad hello").await;
339                }
340                _ => {}
341            }
342            return Err(e);
343        }
344    };
345
346    // Step 3: reply with our hello.
347    if let Err(e) = send_hello(ws, local_hello).await {
348        close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "local send failed").await;
349        return Err(e);
350    }
351
352    Ok(identity)
353}
354
355impl<N: NetworkProvider + 'static> StreamTransport for WebSocketTransport<N> {
356    type Stream = WsFramedStream;
357
358    async fn connect(&self, addr: &PeerAddr) -> Result<Self::Stream, TransportError> {
359        // Step 1: Dial TCP via Layer 3
360        let dial_addr = resolve_dial_addr(addr);
361        tracing::debug!(addr = %dial_addr, port = self.config.port, "ws: dialing peer");
362
363        let tcp_stream = self
364            .network
365            .dial_tcp(&dial_addr, self.config.port)
366            .await
367            .map_err(|e| TransportError::ConnectFailed(format!("dial tcp: {e}")))?;
368
369        // Step 2: WebSocket client upgrade (with max_message_size config)
370        let ws_url = format!("ws://{dial_addr}:{}/ws", self.config.port);
371        let ws_config = self.ws_protocol_config();
372        let (mut ws, _response) =
373            tokio_tungstenite::client_async_with_config(ws_url, tcp_stream, Some(ws_config))
374                .await
375                .map_err(|e| TransportError::ConnectFailed(format!("ws upgrade: {e}")))?;
376
377        // Step 3: Hello exchange (RFC 017 §8)
378        let local_hello = self.local_hello();
379        let remote_identity = match client_hello_exchange(&mut ws, &local_hello).await {
380            Ok(identity) => identity,
381            Err(e) => {
382                match &e {
383                    TransportError::AppMismatch { local, remote } => {
384                        tracing::info!(
385                            local_app_id = %local,
386                            remote_app_id = %remote,
387                            "ws: closing connection — app_id mismatch"
388                        );
389                    }
390                    TransportError::HelloTimeout => {
391                        tracing::warn!("ws: hello timeout on outgoing connection");
392                    }
393                    TransportError::HelloMalformed(msg) => {
394                        tracing::warn!(error = %msg, "ws: malformed hello on outgoing connection");
395                    }
396                    _ => {}
397                }
398                return Err(e);
399            }
400        };
401
402        tracing::info!(
403            remote_device_id = %remote_identity.device_id,
404            remote_device_name = %remote_identity.device_name,
405            remote_tailscale_id = %remote_identity.tailscale_id,
406            "ws: connected (hello exchanged)"
407        );
408
409        // Step 4: Build framed stream with split read/write halves
410        Ok(WsFramedStream::new(
411            ws,
412            remote_identity.tailscale_id.clone(),
413            Some(remote_identity),
414            dial_addr,
415            self.config.ping_interval,
416            self.config.pong_timeout,
417        ))
418    }
419
420    async fn listen(&self) -> Result<StreamListener<Self::Stream>, TransportError> {
421        let port = self.config.port;
422        tracing::debug!(port, "ws: starting listener");
423
424        // Step 1: Listen on TCP via Layer 3
425        let mut tcp_listener = self
426            .network
427            .listen_tcp(port)
428            .await
429            .map_err(|e| TransportError::ListenFailed(format!("listen tcp: {e}")))?;
430
431        // Step 2: Spawn accept loop that upgrades each connection to WS
432        let (tx, rx) = tokio::sync::mpsc::channel::<WsFramedStream>(64);
433        let local_hello = self.local_hello();
434        let ping_interval = self.config.ping_interval;
435        let pong_timeout = self.config.pong_timeout;
436        let ws_config = self.ws_protocol_config();
437
438        tokio::spawn(async move {
439            loop {
440                match tcp_listener.incoming.recv().await {
441                    Some(incoming) => {
442                        let tx = tx.clone();
443                        let local_hello = local_hello.clone();
444                        let remote_addr = incoming.remote_addr.clone();
445                        let ws_config = ws_config;
446
447                        tokio::spawn(async move {
448                            // WS server upgrade (with max_message_size config)
449                            let mut ws = match tokio_tungstenite::accept_async_with_config(
450                                incoming.stream,
451                                Some(ws_config),
452                            )
453                            .await
454                            {
455                                Ok(ws) => ws,
456                                Err(e) => {
457                                    tracing::warn!(
458                                        remote = %remote_addr,
459                                        "ws: upgrade failed: {e}"
460                                    );
461                                    return;
462                                }
463                            };
464
465                            // Server-side hello exchange (with timeout)
466                            let remote_identity = match server_hello_exchange(&mut ws, &local_hello)
467                                .await
468                            {
469                                Ok(identity) => identity,
470                                Err(e) => {
471                                    match &e {
472                                        TransportError::AppMismatch { local, remote } => {
473                                            tracing::info!(
474                                                remote_addr = %remote_addr,
475                                                local_app_id = %local,
476                                                remote_app_id = %remote,
477                                                "ws: closing incoming connection — app_id mismatch"
478                                            );
479                                        }
480                                        TransportError::HelloTimeout => {
481                                            tracing::warn!(
482                                                remote_addr = %remote_addr,
483                                                "ws: hello timeout on incoming connection"
484                                            );
485                                        }
486                                        TransportError::HelloMalformed(msg) => {
487                                            tracing::warn!(
488                                                remote_addr = %remote_addr,
489                                                error = %msg,
490                                                "ws: malformed hello on incoming connection"
491                                            );
492                                        }
493                                        _ => {
494                                            tracing::warn!(
495                                                remote_addr = %remote_addr,
496                                                "ws: hello exchange failed: {e}"
497                                            );
498                                        }
499                                    }
500                                    return;
501                                }
502                            };
503
504                            tracing::info!(
505                                remote_device_id = %remote_identity.device_id,
506                                remote_device_name = %remote_identity.device_name,
507                                remote_tailscale_id = %remote_identity.tailscale_id,
508                                remote_addr = %remote_addr,
509                                "ws: accepted connection (hello exchanged)"
510                            );
511
512                            let stream = WsFramedStream::new(
513                                ws,
514                                remote_identity.tailscale_id.clone(),
515                                Some(remote_identity),
516                                remote_addr,
517                                ping_interval,
518                                pong_timeout,
519                            );
520
521                            if tx.send(stream).await.is_err() {
522                                tracing::debug!("ws: listener channel closed");
523                            }
524                        });
525                    }
526                    None => {
527                        tracing::debug!("ws: tcp listener channel closed");
528                        break;
529                    }
530                }
531            }
532        });
533
534        Ok(StreamListener::new(rx, port))
535    }
536}
537
538// ---------------------------------------------------------------------------
539// WsFramedStream
540// ---------------------------------------------------------------------------
541
542/// A WebSocket-backed [`FramedStream`].
543///
544/// Uses split read/write halves to avoid mutex contention between the
545/// heartbeat task and the main send/recv path:
546///
547/// - **Write half** (`SplitSink`): Shared via `Arc<Mutex<_>>` between
548///   `send()` and the heartbeat task (which sends Ping frames).
549/// - **Read half** (`SplitStream`): Owned exclusively by `recv()` — no
550///   mutex needed.
551/// - **Heartbeat**: Sends Ping on the write half at `ping_interval`. Tracks
552///   last Pong via `Arc<AtomicU64>` (epoch millis). If `last_pong` exceeds
553///   `pong_timeout`, the connection is closed.
554pub struct WsFramedStream {
555    /// Write half of the WebSocket stream, shared with heartbeat task.
556    write: Arc<Mutex<SplitSink<WebSocketStream<TcpStream>, Message>>>,
557    /// Read half of the WebSocket stream, owned exclusively by recv().
558    read: SplitStream<WebSocketStream<TcpStream>>,
559    /// Remote peer's Tailscale stable ID — the session layer routes by this.
560    /// Derived from the hello envelope's `identity.tailscale_id`.
561    remote_peer_id: String,
562    /// Remote peer identity from the hello envelope (RFC 017 §8).
563    remote_identity: Option<PeerIdentity>,
564    /// Remote address string.
565    remote_addr: String,
566    /// Handle to the heartbeat task (aborted on close/drop).
567    heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
568    /// Timestamp (epoch millis) of the last received Pong.
569    last_pong: Arc<AtomicU64>,
570    /// Flag indicating the connection has been closed.
571    closed: Arc<AtomicBool>,
572}
573
574impl std::fmt::Debug for WsFramedStream {
575    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
576        f.debug_struct("WsFramedStream")
577            .field("remote_peer_id", &self.remote_peer_id)
578            .field("remote_addr", &self.remote_addr)
579            .field("closed", &self.closed.load(Ordering::Relaxed))
580            .finish_non_exhaustive()
581    }
582}
583
584// SAFETY: All fields are Send. `SplitStream` is Send because
585// `WebSocketStream<TcpStream>` is Send. The Arc-wrapped fields are Sync.
586// We need the explicit Sync impl because `SplitStream` is not Sync,
587// but `WsFramedStream` is only accessed via `&mut self` (exclusive ref)
588// so Sync is safe.
589unsafe impl Sync for WsFramedStream {}
590
591/// Get the current epoch time in milliseconds.
592fn epoch_millis() -> u64 {
593    std::time::SystemTime::now()
594        .duration_since(std::time::UNIX_EPOCH)
595        .unwrap_or_default()
596        .as_millis() as u64
597}
598
599impl WsFramedStream {
600    /// Create a new framed stream with split read/write halves and heartbeat.
601    fn new(
602        ws: WebSocketStream<TcpStream>,
603        remote_peer_id: String,
604        remote_identity: Option<PeerIdentity>,
605        remote_addr: String,
606        ping_interval: Duration,
607        pong_timeout: Duration,
608    ) -> Self {
609        let (write, read) = ws.split();
610        let write = Arc::new(Mutex::new(write));
611        let last_pong = Arc::new(AtomicU64::new(epoch_millis()));
612        let closed = Arc::new(AtomicBool::new(false));
613
614        // Spawn heartbeat task
615        let hb_write = write.clone();
616        let hb_last_pong = last_pong.clone();
617        let hb_closed = closed.clone();
618        let hb_addr = remote_addr.clone();
619        let heartbeat_handle = tokio::spawn(async move {
620            heartbeat_loop(
621                hb_write,
622                hb_last_pong,
623                hb_closed,
624                ping_interval,
625                pong_timeout,
626                &hb_addr,
627            )
628            .await;
629        });
630
631        Self {
632            write,
633            read,
634            remote_peer_id,
635            remote_identity,
636            remote_addr,
637            heartbeat_handle: Some(heartbeat_handle),
638            last_pong,
639            closed,
640        }
641    }
642
643    /// Get the remote peer's Tailscale stable ID (from the hello envelope).
644    ///
645    /// This is the session layer's routing key. For the application-visible
646    /// device identifier, use [`remote_identity().device_id`](Self::remote_identity).
647    pub fn remote_peer_id(&self) -> &str {
648        &self.remote_peer_id
649    }
650
651    /// Get the full remote [`PeerIdentity`] advertised in the hello envelope.
652    ///
653    /// Returns `None` only in test fixtures that synthesise a stream without
654    /// running the hello exchange — production streams always have this.
655    pub fn remote_identity(&self) -> Option<&PeerIdentity> {
656        self.remote_identity.as_ref()
657    }
658}
659
660/// Background heartbeat loop that sends Ping frames and monitors Pong timestamps.
661///
662/// On each `ping_interval` tick:
663/// 1. Check if `last_pong` is within `pong_timeout` — if not, close.
664/// 2. Send a Ping frame on the write half.
665///
666/// This design avoids the old bug where `select!` between `ping_interval` (10s)
667/// and `pong_timeout` (30s) always picked the shorter timer, making timeout
668/// detection dead code.
669async fn heartbeat_loop(
670    write: Arc<Mutex<SplitSink<WebSocketStream<TcpStream>, Message>>>,
671    last_pong: Arc<AtomicU64>,
672    closed: Arc<AtomicBool>,
673    ping_interval: Duration,
674    pong_timeout: Duration,
675    remote_addr: &str,
676) {
677    let mut interval = tokio::time::interval(ping_interval);
678    // Skip the first immediate tick
679    interval.tick().await;
680
681    loop {
682        interval.tick().await;
683
684        // If the connection was closed externally, stop.
685        if closed.load(Ordering::Acquire) {
686            return;
687        }
688
689        // Check if last pong is within timeout
690        let last = last_pong.load(Ordering::Acquire);
691        let now = epoch_millis();
692        let elapsed = Duration::from_millis(now.saturating_sub(last));
693
694        if elapsed > pong_timeout {
695            tracing::warn!(
696                remote = %remote_addr,
697                elapsed = ?elapsed,
698                "heartbeat: pong timeout after {pong_timeout:?}"
699            );
700            // Close the connection
701            closed.store(true, Ordering::Release);
702            let mut w = write.lock().await;
703            let _ = w.close().await;
704            return;
705        }
706
707        // Send a ping
708        {
709            let mut w = write.lock().await;
710            let ping_data = b"truffle-ping".to_vec();
711            if let Err(e) = w.send(Message::Ping(ping_data.into())).await {
712                tracing::debug!(remote = %remote_addr, "heartbeat: ping send failed: {e}");
713                closed.store(true, Ordering::Release);
714                return;
715            }
716        }
717    }
718}
719
720impl FramedStream for WsFramedStream {
721    async fn send(&mut self, data: &[u8]) -> Result<(), TransportError> {
722        if self.closed.load(Ordering::Acquire) {
723            return Err(TransportError::ConnectionClosed(
724                "connection already closed".to_string(),
725            ));
726        }
727        let mut w = self.write.lock().await;
728        w.send(Message::Binary(data.to_vec().into()))
729            .await
730            .map_err(|e| TransportError::WebSocket(format!("send: {e}")))
731    }
732
733    async fn recv(&mut self) -> Result<Option<Vec<u8>>, TransportError> {
734        if self.closed.load(Ordering::Acquire) {
735            return Ok(None);
736        }
737        loop {
738            match self.read.next().await {
739                Some(Ok(Message::Binary(data))) => return Ok(Some(data.to_vec())),
740                Some(Ok(Message::Text(text))) => {
741                    // Layer 4 treats text frames as binary data
742                    return Ok(Some(text.as_bytes().to_vec()));
743                }
744                Some(Ok(Message::Ping(_))) => {
745                    // We received a Ping — tungstenite auto-sends Pong at the
746                    // protocol level. Just skip and continue reading.
747                    continue;
748                }
749                Some(Ok(Message::Pong(_))) => {
750                    // Update last_pong timestamp for the heartbeat checker
751                    self.last_pong.store(epoch_millis(), Ordering::Release);
752                    continue;
753                }
754                Some(Ok(Message::Close(_))) => {
755                    self.closed.store(true, Ordering::Release);
756                    return Ok(None);
757                }
758                Some(Ok(Message::Frame(_))) => {
759                    // Raw frame — skip
760                    continue;
761                }
762                Some(Err(e)) => {
763                    self.closed.store(true, Ordering::Release);
764                    return Err(TransportError::WebSocket(format!("recv: {e}")));
765                }
766                None => {
767                    self.closed.store(true, Ordering::Release);
768                    return Ok(None);
769                }
770            }
771        }
772    }
773
774    async fn close(&mut self) -> Result<(), TransportError> {
775        // Abort heartbeat task
776        if let Some(handle) = self.heartbeat_handle.take() {
777            handle.abort();
778        }
779
780        self.closed.store(true, Ordering::Release);
781
782        let mut w = self.write.lock().await;
783        w.close()
784            .await
785            .map_err(|e| TransportError::WebSocket(format!("close: {e}")))
786    }
787
788    fn peer_addr(&self) -> String {
789        self.remote_addr.clone()
790    }
791}
792
793impl Drop for WsFramedStream {
794    fn drop(&mut self) {
795        // Ensure heartbeat task is cleaned up
796        if let Some(handle) = self.heartbeat_handle.take() {
797            handle.abort();
798        }
799    }
800}
801
802// ---------------------------------------------------------------------------
803// Unit tests
804// ---------------------------------------------------------------------------
805
806#[cfg(test)]
807mod unit_tests {
808    use super::*;
809
810    #[test]
811    fn resolve_dial_addr_prefers_ip() {
812        let addr = PeerAddr {
813            ip: Some("100.64.0.1".parse().unwrap()),
814            hostname: "peer".to_string(),
815            dns_name: Some("peer.tailnet.ts.net".to_string()),
816        };
817        assert_eq!(resolve_dial_addr(&addr), "100.64.0.1");
818    }
819
820    #[test]
821    fn resolve_dial_addr_falls_back_to_dns() {
822        let addr = PeerAddr {
823            ip: None,
824            hostname: "peer".to_string(),
825            dns_name: Some("peer.tailnet.ts.net".to_string()),
826        };
827        assert_eq!(resolve_dial_addr(&addr), "peer.tailnet.ts.net");
828    }
829
830    #[test]
831    fn resolve_dial_addr_falls_back_to_hostname() {
832        let addr = PeerAddr {
833            ip: None,
834            hostname: "peer".to_string(),
835            dns_name: None,
836        };
837        assert_eq!(resolve_dial_addr(&addr), "peer");
838    }
839
840    // ── RFC 017 fix G: length-bound each hello identity field ─────────
841
842    fn valid_identity() -> crate::session::hello::PeerIdentity {
843        crate::session::hello::PeerIdentity {
844            app_id: "playground".to_string(),
845            device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".to_string(),
846            device_name: "Alice's MacBook".to_string(),
847            os: "darwin".to_string(),
848            tailscale_id: "n1234567890.ts-node".to_string(),
849        }
850    }
851
852    #[test]
853    fn validate_hello_accepts_normal_identity() {
854        let envelope = crate::session::hello::HelloEnvelope::new(valid_identity());
855        let result = validate_hello(envelope, "playground");
856        assert!(result.is_ok(), "baseline valid hello should validate");
857    }
858
859    #[test]
860    fn validate_hello_rejects_oversized_app_id() {
861        let mut identity = valid_identity();
862        identity.app_id = "a".repeat(MAX_APP_ID_LEN + 1);
863        let envelope = crate::session::hello::HelloEnvelope::new(identity);
864        let result = validate_hello(envelope, "playground");
865        match result {
866            Err(TransportError::HelloMalformed(msg)) => {
867                assert!(
868                    msg.contains("maximum allowed length"),
869                    "unexpected error: {msg}"
870                );
871            }
872            other => panic!("expected HelloMalformed, got {other:?}"),
873        }
874    }
875
876    #[test]
877    fn validate_hello_rejects_oversized_device_name() {
878        let mut identity = valid_identity();
879        identity.device_name = "x".repeat(MAX_DEVICE_NAME_LEN + 1);
880        let envelope = crate::session::hello::HelloEnvelope::new(identity);
881        let result = validate_hello(envelope, "playground");
882        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
883    }
884
885    #[test]
886    fn validate_hello_rejects_oversized_device_id() {
887        let mut identity = valid_identity();
888        identity.device_id = "x".repeat(MAX_DEVICE_ID_LEN + 1);
889        let envelope = crate::session::hello::HelloEnvelope::new(identity);
890        let result = validate_hello(envelope, "playground");
891        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
892    }
893
894    #[test]
895    fn validate_hello_rejects_oversized_tailscale_id() {
896        let mut identity = valid_identity();
897        identity.tailscale_id = "x".repeat(MAX_TAILSCALE_ID_LEN + 1);
898        let envelope = crate::session::hello::HelloEnvelope::new(identity);
899        let result = validate_hello(envelope, "playground");
900        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
901    }
902
903    #[test]
904    fn validate_hello_rejects_oversized_os() {
905        let mut identity = valid_identity();
906        identity.os = "x".repeat(MAX_OS_LEN + 1);
907        let envelope = crate::session::hello::HelloEnvelope::new(identity);
908        let result = validate_hello(envelope, "playground");
909        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
910    }
911
912    #[test]
913    fn validate_hello_length_check_runs_before_app_id_mismatch() {
914        // Even with a matching app_id, an oversized field still fails
915        // with HelloMalformed (not AppMismatch). This is the
916        // "oversized field on matched app_id" attack path from the
917        // review.
918        let mut identity = valid_identity();
919        identity.device_name = "x".repeat(MAX_DEVICE_NAME_LEN + 1);
920        let envelope = crate::session::hello::HelloEnvelope::new(identity);
921        let result = validate_hello(envelope, "playground");
922        assert!(
923            matches!(result, Err(TransportError::HelloMalformed(_))),
924            "matched app_id must not let an oversized field through"
925        );
926    }
927}