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, verify the claimed
19//!    `tailscale_id` against the bridge-provided WhoIs identity (closing with
20//!    code 4003 on mismatch), then send own [`HelloEnvelope`]
21//! 4. Split stream into read/write halves, yield [`WsFramedStream`] via [`StreamListener`]
22//!
23//! Each incoming handshake (steps 2-3) is bounded two ways: at most
24//! `WsConfig::max_pending_handshakes` may be in flight at once (excess
25//! connections are dropped before the upgrade) and each must complete within
26//! `WsConfig::handshake_timeout` or the connection is dropped.
27//!
28//! The `app_id` check happens during the hello exchange: a mismatch closes
29//! the socket with close code 4001 so the remote sees a specific reason. A
30//! missing or malformed hello closes with code 4002.
31//!
32//! # Heartbeat
33//!
34//! After the hello exchange, a background task sends WebSocket Ping frames
35//! at `WsConfig::ping_interval` on the write half. The read half updates a
36//! shared `last_pong` timestamp when it receives a Pong. The heartbeat task
37//! checks this timestamp on each ping cycle and closes the connection if
38//! `pong_timeout` has been exceeded.
39
40use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
41use std::sync::Arc;
42use std::time::Duration;
43
44use futures_util::stream::SplitSink;
45use futures_util::stream::SplitStream;
46use futures_util::{SinkExt, StreamExt};
47use tokio::net::TcpStream;
48use tokio::sync::Mutex;
49use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode;
50use tokio_tungstenite::tungstenite::protocol::frame::CloseFrame;
51use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
52use tokio_tungstenite::tungstenite::Message;
53use tokio_tungstenite::WebSocketStream;
54
55use crate::network::{NetworkProvider, PeerAddr};
56use crate::session::hello::{
57    HelloEnvelope, HelloKind, PeerIdentity, CLOSE_APP_MISMATCH, CLOSE_HELLO_PROTOCOL,
58    CLOSE_IDENTITY_MISMATCH, HELLO_TIMEOUT,
59};
60
61use super::{
62    resolve_dial_addr, FramedStream, StreamListener, StreamTransport, TransportError, WsConfig,
63};
64
65// ---------------------------------------------------------------------------
66// WebSocketTransport
67// ---------------------------------------------------------------------------
68
69/// WebSocket-based [`StreamTransport`] implementation.
70///
71/// Generic over the [`NetworkProvider`] type `N`. Owns a shared reference
72/// to Layer 3 for establishing raw TCP connections, and a [`WsConfig`]
73/// for protocol parameters.
74///
75/// # Example
76///
77/// ```ignore
78/// use std::sync::Arc;
79/// use truffle_core::transport::{WsConfig, websocket::WebSocketTransport};
80///
81/// let ws = WebSocketTransport::new(Arc::new(provider), WsConfig::default());
82/// let stream = ws.connect(&peer_addr).await?;
83/// ```
84pub struct WebSocketTransport<N: NetworkProvider> {
85    /// Layer 3 network provider for raw TCP dial/listen.
86    network: Arc<N>,
87    /// WebSocket configuration.
88    config: WsConfig,
89}
90
91impl<N: NetworkProvider + 'static> WebSocketTransport<N> {
92    /// Create a new WebSocket transport.
93    ///
94    /// - `network`: An `Arc<N>` where `N: NetworkProvider`.
95    /// - `config`: WebSocket configuration (port, heartbeat intervals, etc.).
96    pub fn new(network: Arc<N>, config: WsConfig) -> Self {
97        Self { network, config }
98    }
99
100    /// Build the local hello envelope from the network provider's identity.
101    ///
102    /// RFC 017 Phase 2: the transport handshake is now a [`HelloEnvelope`]
103    /// carrying `app_id` + `device_id` + `device_name` + `os` + an escape
104    /// hatch `tailscale_id` used by the session layer to correlate the
105    /// link with its Layer 3 peer entry.
106    fn local_hello(&self) -> HelloEnvelope {
107        let identity = self.network.local_identity();
108        HelloEnvelope::new(PeerIdentity {
109            app_id: identity.app_id,
110            device_id: identity.device_id,
111            device_name: identity.device_name,
112            os: std::env::consts::OS.to_string(),
113            tailscale_id: identity.tailscale_id,
114        })
115    }
116
117    /// Build a tungstenite `WebSocketConfig` from our `WsConfig`.
118    fn ws_protocol_config(&self) -> WebSocketConfig {
119        let mut config = WebSocketConfig::default();
120        config.max_message_size = Some(self.config.max_message_size);
121        config.max_frame_size = Some(self.config.max_message_size);
122        config
123    }
124}
125
126/// Send a close frame to the remote and drop the stream.
127///
128/// Tries to deliver the close code so the peer can log a specific reason for
129/// the drop. Errors from the close path are swallowed — at this point the
130/// connection is already doomed and the caller will propagate a richer
131/// [`TransportError`].
132async fn close_ws_with_code(ws: &mut WebSocketStream<TcpStream>, code: u16, reason: &str) {
133    let close_frame = CloseFrame {
134        code: CloseCode::from(code),
135        reason: reason.to_string().into(),
136    };
137    let _ = ws.send(Message::Close(Some(close_frame))).await;
138    let _ = ws.close(None).await;
139}
140
141/// Maximum number of control frames (Ping/Pong/Frame) we tolerate from a
142/// peer before the first application-level hello frame arrives. A well-
143/// behaved client sends at most one Pong here; anything higher is either a
144/// broken client or an active DoS against the 5s hello window.
145const MAX_CONTROL_FRAMES_BEFORE_HELLO: usize = 16;
146
147/// Receive and parse a [`HelloEnvelope`] from a WebSocket stream, applying a
148/// 5 second timeout.
149///
150/// Consumes frames from the stream until the first application-level message
151/// (Text or Binary) arrives. Control frames (Ping, Pong, Frame) are handled
152/// transparently, but we cap the number tolerated before the hello so a
153/// malicious peer can't flood Pings for the full timeout window. Returns a
154/// specific `TransportError` for each failure mode so the caller can emit
155/// the right close code.
156async fn receive_hello(
157    ws: &mut WebSocketStream<TcpStream>,
158) -> Result<HelloEnvelope, TransportError> {
159    let fut = async {
160        let mut control_frame_count: usize = 0;
161        loop {
162            match ws.next().await {
163                Some(Ok(Message::Text(text))) => {
164                    return serde_json::from_str::<HelloEnvelope>(&text)
165                        .map_err(|e| TransportError::HelloMalformed(format!("parse hello: {e}")));
166                }
167                Some(Ok(Message::Binary(data))) => {
168                    return serde_json::from_slice::<HelloEnvelope>(&data)
169                        .map_err(|e| TransportError::HelloMalformed(format!("parse hello: {e}")));
170                }
171                Some(Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_))) => {
172                    control_frame_count += 1;
173                    if control_frame_count > MAX_CONTROL_FRAMES_BEFORE_HELLO {
174                        return Err(TransportError::HelloMalformed(
175                            "too many control frames before hello".to_string(),
176                        ));
177                    }
178                    continue;
179                }
180                Some(Ok(Message::Close(_))) => {
181                    return Err(TransportError::HelloMalformed(
182                        "peer closed connection before hello".to_string(),
183                    ));
184                }
185                Some(Err(e)) => {
186                    return Err(TransportError::HelloMalformed(format!(
187                        "receive hello: {e}"
188                    )));
189                }
190                None => {
191                    return Err(TransportError::HelloMalformed(
192                        "connection closed before hello".to_string(),
193                    ));
194                }
195            }
196        }
197    };
198
199    match tokio::time::timeout(HELLO_TIMEOUT, fut).await {
200        Ok(inner) => inner,
201        Err(_) => Err(TransportError::HelloTimeout),
202    }
203}
204
205// Maximum byte lengths for each string field in a received PeerIdentity.
206// Enforced BEFORE the app_id mismatch check so an oversized field can't
207// sneak past via a matched app_id. Sized ~2x the RFC 017 §5 soft caps.
208const MAX_APP_ID_LEN: usize = 32;
209const MAX_DEVICE_NAME_LEN: usize = 512;
210const MAX_DEVICE_ID_LEN: usize = 64;
211const MAX_TAILSCALE_ID_LEN: usize = 256;
212const MAX_OS_LEN: usize = 32;
213
214/// Validate a received hello against our own. Returns the remote identity
215/// on success, or a classified error that maps to an RFC 017 close code.
216fn validate_hello(
217    remote: HelloEnvelope,
218    local_app_id: &str,
219) -> Result<PeerIdentity, TransportError> {
220    if remote.kind != HelloKind::Hello {
221        return Err(TransportError::HelloMalformed(format!(
222            "unexpected hello kind: {:?}",
223            remote.kind
224        )));
225    }
226    if remote.version < HelloEnvelope::MIN_SUPPORTED_VERSION {
227        return Err(TransportError::HelloMalformed(format!(
228            "unsupported hello version {} (minimum supported: {})",
229            remote.version,
230            HelloEnvelope::MIN_SUPPORTED_VERSION
231        )));
232    }
233    // Length-bound each identity field BEFORE the app_id mismatch check
234    // so an oversized field can't sneak through on a matched app_id.
235    if remote.identity.app_id.len() > MAX_APP_ID_LEN
236        || remote.identity.device_name.len() > MAX_DEVICE_NAME_LEN
237        || remote.identity.device_id.len() > MAX_DEVICE_ID_LEN
238        || remote.identity.tailscale_id.len() > MAX_TAILSCALE_ID_LEN
239        || remote.identity.os.len() > MAX_OS_LEN
240    {
241        return Err(TransportError::HelloMalformed(
242            "identity field exceeds maximum allowed length".to_string(),
243        ));
244    }
245    // RFC 022 I1 at the trust boundary: the durable device_id must be
246    // non-empty and distinct from the Tailscale routing id. Without this, a
247    // remote hello could publish a Tailscale id (or nothing) as its durable
248    // identity — every consumer of the honest projection inherits the lie,
249    // and the projection's I1 debug_assert becomes a remote-triggered panic
250    // in debug builds.
251    if remote.identity.device_id.is_empty()
252        || remote.identity.device_id == remote.identity.tailscale_id
253    {
254        return Err(TransportError::HelloMalformed(
255            "identity device_id must be non-empty and distinct from tailscale_id".to_string(),
256        ));
257    }
258    if remote.identity.app_id != local_app_id {
259        return Err(TransportError::AppMismatch {
260            local: local_app_id.to_string(),
261            remote: remote.identity.app_id.clone(),
262        });
263    }
264    Ok(remote.identity)
265}
266
267/// Subset of the sidecar's WhoIs-derived peer identity JSON
268/// (`peerIdentityData` in sidecar-slim) carried in the bridge header.
269#[derive(Debug, Default, serde::Deserialize)]
270struct AuthenticatedIdentity {
271    #[serde(default, rename = "nodeId")]
272    node_id: String,
273}
274
275/// Verify the hello's self-declared `tailscale_id` against the
276/// Tailscale-authenticated identity (WhoIs) the sidecar attached to the
277/// incoming connection (RFC 017 §8, close code 4003).
278///
279/// `authenticated` is the raw `remote_identity` string from the bridge header.
280/// The policy deliberately fails open when there is no trustworthy
281/// authenticated identity to compare against, so mock/loopback fixtures and
282/// WhoIs failures keep working as before, and only rejects a hello when the
283/// bridge gave us a concrete node ID that contradicts the claim:
284///
285/// - Empty `authenticated` → accept unverified (MockNetworkProvider, or the
286///   sidecar returned `""` because WhoIs failed).
287/// - Unparseable `authenticated` → accept unverified (legacy plain-DNS-name
288///   form of the bridge header field).
289/// - Parsed but empty `nodeId` → accept unverified (WhoIs returned no Node).
290/// - `nodeId == claimed.tailscale_id` → accept.
291/// - Otherwise → [`TransportError::IdentityMismatch`].
292fn verify_authenticated_identity(
293    claimed: &PeerIdentity,
294    authenticated: &str,
295) -> Result<(), TransportError> {
296    if authenticated.trim().is_empty() {
297        tracing::warn!(
298            claimed_tailscale_id = %claimed.tailscale_id,
299            "ws: no authenticated identity for incoming connection (mock/loopback or WhoIs failure); accepting hello claim unverified"
300        );
301        return Ok(());
302    }
303
304    let parsed = match serde_json::from_str::<AuthenticatedIdentity>(authenticated) {
305        Ok(parsed) => parsed,
306        Err(_) => {
307            tracing::warn!(
308                claimed_tailscale_id = %claimed.tailscale_id,
309                "ws: unparseable authenticated identity; accepting hello claim unverified"
310            );
311            return Ok(());
312        }
313    };
314
315    if parsed.node_id.is_empty() {
316        tracing::warn!(
317            claimed_tailscale_id = %claimed.tailscale_id,
318            "ws: authenticated identity carried no nodeId; accepting hello claim unverified"
319        );
320        return Ok(());
321    }
322
323    if parsed.node_id == claimed.tailscale_id {
324        Ok(())
325    } else {
326        Err(TransportError::IdentityMismatch {
327            claimed: claimed.tailscale_id.clone(),
328            authenticated: parsed.node_id,
329        })
330    }
331}
332
333/// Serialise and send a [`HelloEnvelope`] as a text frame.
334async fn send_hello(
335    ws: &mut WebSocketStream<TcpStream>,
336    hello: &HelloEnvelope,
337) -> Result<(), TransportError> {
338    let payload =
339        serde_json::to_string(hello).map_err(|e| TransportError::Serialize(e.to_string()))?;
340    ws.send(Message::Text(payload.into()))
341        .await
342        .map_err(|e| TransportError::HandshakeFailed(format!("send hello: {e}")))
343}
344
345/// Client-side hello exchange: send ours first, read theirs, validate, then
346/// return the remote identity. On error, close the socket with the matching
347/// RFC 017 close code before returning the error.
348async fn client_hello_exchange(
349    ws: &mut WebSocketStream<TcpStream>,
350    local_hello: &HelloEnvelope,
351) -> Result<PeerIdentity, TransportError> {
352    // Step 1: send our hello first — this is envelope zero.
353    if let Err(e) = send_hello(ws, local_hello).await {
354        close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "local send failed").await;
355        return Err(e);
356    }
357
358    // Step 2: read remote hello (timed).
359    let remote = match receive_hello(ws).await {
360        Ok(envelope) => envelope,
361        Err(e) => {
362            match &e {
363                TransportError::HelloTimeout => {
364                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "hello timeout").await;
365                }
366                TransportError::HelloMalformed(_) => {
367                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "malformed hello").await;
368                }
369                _ => {}
370            }
371            return Err(e);
372        }
373    };
374
375    // Step 3: validate against our expectations.
376    match validate_hello(remote, &local_hello.identity.app_id) {
377        Ok(identity) => Ok(identity),
378        Err(e) => {
379            match &e {
380                TransportError::AppMismatch { .. } => {
381                    close_ws_with_code(ws, CLOSE_APP_MISMATCH, "app mismatch").await;
382                }
383                TransportError::HelloMalformed(_) => {
384                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "bad hello").await;
385                }
386                _ => {}
387            }
388            Err(e)
389        }
390    }
391}
392
393/// Server-side hello exchange: read theirs first, validate, then send ours.
394/// On error, close the socket with the matching RFC 017 close code.
395///
396/// `authenticated_identity` is the bridge-provided WhoIs identity of the
397/// incoming connection ([`IncomingConnection::remote_identity`]). The claimed
398/// `tailscale_id` is verified against it (close code 4003) BEFORE we reveal our
399/// own hello, so an impersonator never learns our identity and no stream is
400/// yielded for a mismatched peer.
401async fn server_hello_exchange(
402    ws: &mut WebSocketStream<TcpStream>,
403    local_hello: &HelloEnvelope,
404    authenticated_identity: &str,
405) -> Result<PeerIdentity, TransportError> {
406    // Step 1: read remote hello (timed).
407    let remote = match receive_hello(ws).await {
408        Ok(envelope) => envelope,
409        Err(e) => {
410            match &e {
411                TransportError::HelloTimeout => {
412                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "hello timeout").await;
413                }
414                TransportError::HelloMalformed(_) => {
415                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "malformed hello").await;
416                }
417                _ => {}
418            }
419            return Err(e);
420        }
421    };
422
423    // Step 2: validate against our expectations.
424    let identity = match validate_hello(remote, &local_hello.identity.app_id) {
425        Ok(identity) => identity,
426        Err(e) => {
427            match &e {
428                TransportError::AppMismatch { .. } => {
429                    close_ws_with_code(ws, CLOSE_APP_MISMATCH, "app mismatch").await;
430                }
431                TransportError::HelloMalformed(_) => {
432                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "bad hello").await;
433                }
434                _ => {}
435            }
436            return Err(e);
437        }
438    };
439
440    // Step 2.5: verify the claimed tailscale_id against the Tailscale-
441    // authenticated identity BEFORE we reveal our own hello. Doing this
442    // before send_hello means an impersonator never receives our identity
443    // block and the stream is never yielded to the session layer.
444    if let Err(e) = verify_authenticated_identity(&identity, authenticated_identity) {
445        close_ws_with_code(ws, CLOSE_IDENTITY_MISMATCH, "identity mismatch").await;
446        return Err(e);
447    }
448
449    // Step 3: reply with our hello.
450    if let Err(e) = send_hello(ws, local_hello).await {
451        close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "local send failed").await;
452        return Err(e);
453    }
454
455    Ok(identity)
456}
457
458impl<N: NetworkProvider + 'static> StreamTransport for WebSocketTransport<N> {
459    type Stream = WsFramedStream;
460
461    async fn connect(&self, addr: &PeerAddr) -> Result<Self::Stream, TransportError> {
462        // Step 1: Dial TCP via Layer 3
463        let dial_addr = resolve_dial_addr(addr);
464        tracing::debug!(addr = %dial_addr, port = self.config.port, "ws: dialing peer");
465
466        let tcp_stream = self
467            .network
468            .dial_tcp(&dial_addr, self.config.port)
469            .await
470            .map_err(|e| TransportError::ConnectFailed(format!("dial tcp: {e}")))?;
471
472        // Step 2: WebSocket client upgrade (with max_message_size config)
473        let ws_url = format!("ws://{dial_addr}:{}/ws", self.config.port);
474        let ws_config = self.ws_protocol_config();
475        let (mut ws, _response) =
476            tokio_tungstenite::client_async_with_config(ws_url, tcp_stream, Some(ws_config))
477                .await
478                .map_err(|e| TransportError::ConnectFailed(format!("ws upgrade: {e}")))?;
479
480        // Step 3: Hello exchange (RFC 017 §8)
481        let local_hello = self.local_hello();
482        let remote_identity = match client_hello_exchange(&mut ws, &local_hello).await {
483            Ok(identity) => identity,
484            Err(e) => {
485                match &e {
486                    TransportError::AppMismatch { local, remote } => {
487                        tracing::info!(
488                            local_app_id = %local,
489                            remote_app_id = %remote,
490                            "ws: closing connection — app_id mismatch"
491                        );
492                    }
493                    TransportError::HelloTimeout => {
494                        tracing::warn!("ws: hello timeout on outgoing connection");
495                    }
496                    TransportError::HelloMalformed(msg) => {
497                        tracing::warn!(error = %msg, "ws: malformed hello on outgoing connection");
498                    }
499                    _ => {}
500                }
501                return Err(e);
502            }
503        };
504
505        tracing::info!(
506            remote_device_id = %remote_identity.device_id,
507            remote_device_name = %remote_identity.device_name,
508            remote_tailscale_id = %remote_identity.tailscale_id,
509            "ws: connected (hello exchanged)"
510        );
511
512        // Step 4: Build framed stream with split read/write halves
513        Ok(WsFramedStream::new(
514            ws,
515            remote_identity.tailscale_id.clone(),
516            Some(remote_identity),
517            dial_addr,
518            self.config.ping_interval,
519            self.config.pong_timeout,
520        ))
521    }
522
523    async fn listen(&self) -> Result<StreamListener<Self::Stream>, TransportError> {
524        let port = self.config.port;
525        tracing::debug!(port, "ws: starting listener");
526
527        // Step 1: Listen on TCP via Layer 3
528        let mut tcp_listener = self
529            .network
530            .listen_tcp(port)
531            .await
532            .map_err(|e| TransportError::ListenFailed(format!("listen tcp: {e}")))?;
533
534        // Step 2: Spawn accept loop that upgrades each connection to WS
535        let (tx, rx) = tokio::sync::mpsc::channel::<WsFramedStream>(64);
536        let local_hello = self.local_hello();
537        let ping_interval = self.config.ping_interval;
538        let pong_timeout = self.config.pong_timeout;
539        let ws_config = self.ws_protocol_config();
540        let handshake_timeout = self.config.handshake_timeout;
541        let handshake_permits = Arc::new(tokio::sync::Semaphore::new(
542            self.config.max_pending_handshakes,
543        ));
544
545        tokio::spawn(async move {
546            loop {
547                match tcp_listener.incoming.recv().await {
548                    Some(incoming) => {
549                        // Bound in-flight handshakes: claim a permit before we
550                        // commit to upgrading this connection. Beyond the cap we
551                        // drop the connection pre-upgrade (`continue` drops
552                        // `incoming`, closing its TCP stream) — established
553                        // connections are never counted here.
554                        let permit = match handshake_permits.clone().try_acquire_owned() {
555                            Ok(permit) => permit,
556                            Err(_) => {
557                                tracing::warn!(
558                                    remote = %incoming.remote_addr,
559                                    "ws: max pending handshakes reached; dropping incoming connection"
560                                );
561                                continue;
562                            }
563                        };
564
565                        let tx = tx.clone();
566                        let local_hello = local_hello.clone();
567                        let remote_addr = incoming.remote_addr.clone();
568                        // Clone the bridge-provided WhoIs identity before
569                        // `incoming.stream` is moved into the WS upgrade below.
570                        let authenticated_identity = incoming.remote_identity.clone();
571                        let ws_config = ws_config;
572
573                        tokio::spawn(async move {
574                            // Run the WS upgrade + hello exchange under a single
575                            // timeout so a peer that stalls mid-upgrade (before
576                            // the 5s hello window even opens) can't pin the
577                            // handshake slot indefinitely.
578                            let handshake = async {
579                                let mut ws = tokio_tungstenite::accept_async_with_config(
580                                    incoming.stream,
581                                    Some(ws_config),
582                                )
583                                .await
584                                .map_err(|e| {
585                                    TransportError::HandshakeFailed(format!("ws upgrade: {e}"))
586                                })?;
587                                let identity = server_hello_exchange(
588                                    &mut ws,
589                                    &local_hello,
590                                    &authenticated_identity,
591                                )
592                                .await?;
593                                Ok::<(WebSocketStream<TcpStream>, PeerIdentity), TransportError>((
594                                    ws, identity,
595                                ))
596                            };
597
598                            let (ws, remote_identity) = match tokio::time::timeout(
599                                handshake_timeout,
600                                handshake,
601                            )
602                            .await
603                            {
604                                Ok(Ok(pair)) => pair,
605                                Ok(Err(e)) => {
606                                    match &e {
607                                        TransportError::AppMismatch { local, remote } => {
608                                            tracing::info!(
609                                                remote_addr = %remote_addr,
610                                                local_app_id = %local,
611                                                remote_app_id = %remote,
612                                                "ws: closing incoming connection — app_id mismatch"
613                                            );
614                                        }
615                                        TransportError::HelloTimeout => {
616                                            tracing::warn!(
617                                                remote_addr = %remote_addr,
618                                                "ws: hello timeout on incoming connection"
619                                            );
620                                        }
621                                        TransportError::HelloMalformed(msg) => {
622                                            tracing::warn!(
623                                                remote_addr = %remote_addr,
624                                                error = %msg,
625                                                "ws: malformed hello on incoming connection"
626                                            );
627                                        }
628                                        TransportError::IdentityMismatch {
629                                            claimed,
630                                            authenticated,
631                                        } => {
632                                            tracing::warn!(
633                                                remote_addr = %remote_addr,
634                                                claimed = %claimed,
635                                                authenticated = %authenticated,
636                                                "ws: closing incoming connection — hello identity does not match authenticated Tailscale identity"
637                                            );
638                                        }
639                                        TransportError::HandshakeFailed(msg) => {
640                                            tracing::warn!(
641                                                remote = %remote_addr,
642                                                error = %msg,
643                                                "ws: upgrade failed"
644                                            );
645                                        }
646                                        _ => {
647                                            tracing::warn!(
648                                                remote_addr = %remote_addr,
649                                                "ws: hello exchange failed: {e}"
650                                            );
651                                        }
652                                    }
653                                    return;
654                                }
655                                Err(_) => {
656                                    tracing::warn!(
657                                        remote_addr = %remote_addr,
658                                        timeout = ?handshake_timeout,
659                                        "ws: handshake timed out before hello; dropping connection"
660                                    );
661                                    return;
662                                }
663                            };
664
665                            // Release the handshake slot before handing the
666                            // established stream to the listener — the yielded
667                            // connection is no longer counted against the cap.
668                            drop(permit);
669
670                            tracing::info!(
671                                remote_device_id = %remote_identity.device_id,
672                                remote_device_name = %remote_identity.device_name,
673                                remote_tailscale_id = %remote_identity.tailscale_id,
674                                remote_addr = %remote_addr,
675                                "ws: accepted connection (hello exchanged)"
676                            );
677
678                            let stream = WsFramedStream::new(
679                                ws,
680                                remote_identity.tailscale_id.clone(),
681                                Some(remote_identity),
682                                remote_addr,
683                                ping_interval,
684                                pong_timeout,
685                            );
686
687                            if tx.send(stream).await.is_err() {
688                                tracing::debug!("ws: listener channel closed");
689                            }
690                        });
691                    }
692                    None => {
693                        tracing::debug!("ws: tcp listener channel closed");
694                        break;
695                    }
696                }
697            }
698        });
699
700        Ok(StreamListener::new(rx, port))
701    }
702}
703
704// ---------------------------------------------------------------------------
705// WsFramedStream
706// ---------------------------------------------------------------------------
707
708/// A WebSocket-backed [`FramedStream`].
709///
710/// Uses split read/write halves to avoid mutex contention between the
711/// heartbeat task and the main send/recv path:
712///
713/// - **Write half** (`SplitSink`): Shared via `Arc<Mutex<_>>` between
714///   `send()` and the heartbeat task (which sends Ping frames).
715/// - **Read half** (`SplitStream`): Owned exclusively by `recv()` — no
716///   mutex needed.
717/// - **Heartbeat**: Sends Ping on the write half at `ping_interval`. Tracks
718///   last Pong via `Arc<AtomicU64>` (epoch millis). If `last_pong` exceeds
719///   `pong_timeout`, the connection is closed.
720pub struct WsFramedStream {
721    /// Write half of the WebSocket stream, shared with heartbeat task.
722    write: Arc<Mutex<SplitSink<WebSocketStream<TcpStream>, Message>>>,
723    /// Read half of the WebSocket stream, owned exclusively by recv().
724    read: SplitStream<WebSocketStream<TcpStream>>,
725    /// Remote peer's Tailscale stable ID — the session layer routes by this.
726    /// Derived from the hello envelope's `identity.tailscale_id`.
727    remote_peer_id: String,
728    /// Remote peer identity from the hello envelope (RFC 017 §8).
729    remote_identity: Option<PeerIdentity>,
730    /// Remote address string.
731    remote_addr: String,
732    /// Handle to the heartbeat task (aborted on close/drop).
733    heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
734    /// Timestamp (epoch millis) of the last received Pong.
735    last_pong: Arc<AtomicU64>,
736    /// Flag indicating the connection has been closed.
737    closed: Arc<AtomicBool>,
738}
739
740impl std::fmt::Debug for WsFramedStream {
741    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
742        f.debug_struct("WsFramedStream")
743            .field("remote_peer_id", &self.remote_peer_id)
744            .field("remote_addr", &self.remote_addr)
745            .field("closed", &self.closed.load(Ordering::Relaxed))
746            .finish_non_exhaustive()
747    }
748}
749
750// SAFETY: All fields are Send. `SplitStream` is Send because
751// `WebSocketStream<TcpStream>` is Send. The Arc-wrapped fields are Sync.
752// We need the explicit Sync impl because `SplitStream` is not Sync,
753// but `WsFramedStream` is only accessed via `&mut self` (exclusive ref)
754// so Sync is safe.
755#[allow(unsafe_code)]
756unsafe impl Sync for WsFramedStream {}
757
758/// Get the current epoch time in milliseconds.
759fn epoch_millis() -> u64 {
760    std::time::SystemTime::now()
761        .duration_since(std::time::UNIX_EPOCH)
762        .unwrap_or_default()
763        .as_millis() as u64
764}
765
766impl WsFramedStream {
767    /// Create a new framed stream with split read/write halves and heartbeat.
768    fn new(
769        ws: WebSocketStream<TcpStream>,
770        remote_peer_id: String,
771        remote_identity: Option<PeerIdentity>,
772        remote_addr: String,
773        ping_interval: Duration,
774        pong_timeout: Duration,
775    ) -> Self {
776        let (write, read) = ws.split();
777        let write = Arc::new(Mutex::new(write));
778        let last_pong = Arc::new(AtomicU64::new(epoch_millis()));
779        let closed = Arc::new(AtomicBool::new(false));
780
781        // Spawn heartbeat task
782        let hb_write = write.clone();
783        let hb_last_pong = last_pong.clone();
784        let hb_closed = closed.clone();
785        let hb_addr = remote_addr.clone();
786        let heartbeat_handle = tokio::spawn(async move {
787            heartbeat_loop(
788                hb_write,
789                hb_last_pong,
790                hb_closed,
791                ping_interval,
792                pong_timeout,
793                &hb_addr,
794            )
795            .await;
796        });
797
798        Self {
799            write,
800            read,
801            remote_peer_id,
802            remote_identity,
803            remote_addr,
804            heartbeat_handle: Some(heartbeat_handle),
805            last_pong,
806            closed,
807        }
808    }
809
810    /// Get the remote peer's Tailscale stable ID (from the hello envelope).
811    ///
812    /// This is the session layer's routing key. For the application-visible
813    /// device identifier, use [`remote_identity().device_id`](Self::remote_identity).
814    pub fn remote_peer_id(&self) -> &str {
815        &self.remote_peer_id
816    }
817
818    /// Get the full remote [`PeerIdentity`] advertised in the hello envelope.
819    ///
820    /// Returns `None` only in test fixtures that synthesise a stream without
821    /// running the hello exchange — production streams always have this.
822    pub fn remote_identity(&self) -> Option<&PeerIdentity> {
823        self.remote_identity.as_ref()
824    }
825}
826
827/// Background heartbeat loop that sends Ping frames and monitors Pong timestamps.
828///
829/// On each `ping_interval` tick:
830/// 1. Check if `last_pong` is within `pong_timeout` — if not, close.
831/// 2. Send a Ping frame on the write half.
832///
833/// This design avoids the old bug where `select!` between `ping_interval` (10s)
834/// and `pong_timeout` (30s) always picked the shorter timer, making timeout
835/// detection dead code.
836async fn heartbeat_loop(
837    write: Arc<Mutex<SplitSink<WebSocketStream<TcpStream>, Message>>>,
838    last_pong: Arc<AtomicU64>,
839    closed: Arc<AtomicBool>,
840    ping_interval: Duration,
841    pong_timeout: Duration,
842    remote_addr: &str,
843) {
844    let mut interval = tokio::time::interval(ping_interval);
845    // Skip the first immediate tick
846    interval.tick().await;
847
848    loop {
849        interval.tick().await;
850
851        // If the connection was closed externally, stop.
852        if closed.load(Ordering::Acquire) {
853            return;
854        }
855
856        // Check if last pong is within timeout
857        let last = last_pong.load(Ordering::Acquire);
858        let now = epoch_millis();
859        let elapsed = Duration::from_millis(now.saturating_sub(last));
860
861        if elapsed > pong_timeout {
862            tracing::warn!(
863                remote = %remote_addr,
864                elapsed = ?elapsed,
865                "heartbeat: pong timeout after {pong_timeout:?}"
866            );
867            // Close the connection
868            closed.store(true, Ordering::Release);
869            let mut w = write.lock().await;
870            let _ = w.close().await;
871            return;
872        }
873
874        // Send a ping
875        {
876            let mut w = write.lock().await;
877            let ping_data = b"truffle-ping".to_vec();
878            if let Err(e) = w.send(Message::Ping(ping_data.into())).await {
879                tracing::debug!(remote = %remote_addr, "heartbeat: ping send failed: {e}");
880                closed.store(true, Ordering::Release);
881                return;
882            }
883        }
884    }
885}
886
887impl FramedStream for WsFramedStream {
888    async fn send(&mut self, data: &[u8]) -> Result<(), TransportError> {
889        if self.closed.load(Ordering::Acquire) {
890            return Err(TransportError::ConnectionClosed(
891                "connection already closed".to_string(),
892            ));
893        }
894        let mut w = self.write.lock().await;
895        w.send(Message::Binary(data.to_vec().into()))
896            .await
897            .map_err(|e| TransportError::WebSocket(format!("send: {e}")))
898    }
899
900    async fn recv(&mut self) -> Result<Option<Vec<u8>>, TransportError> {
901        if self.closed.load(Ordering::Acquire) {
902            return Ok(None);
903        }
904        loop {
905            match self.read.next().await {
906                Some(Ok(Message::Binary(data))) => return Ok(Some(data.to_vec())),
907                Some(Ok(Message::Text(text))) => {
908                    // Layer 4 treats text frames as binary data
909                    return Ok(Some(text.as_bytes().to_vec()));
910                }
911                Some(Ok(Message::Ping(_))) => {
912                    // We received a Ping — tungstenite auto-sends Pong at the
913                    // protocol level. Just skip and continue reading.
914                    continue;
915                }
916                Some(Ok(Message::Pong(_))) => {
917                    // Update last_pong timestamp for the heartbeat checker
918                    self.last_pong.store(epoch_millis(), Ordering::Release);
919                    continue;
920                }
921                Some(Ok(Message::Close(_))) => {
922                    self.closed.store(true, Ordering::Release);
923                    return Ok(None);
924                }
925                Some(Ok(Message::Frame(_))) => {
926                    // Raw frame — skip
927                    continue;
928                }
929                Some(Err(e)) => {
930                    self.closed.store(true, Ordering::Release);
931                    return Err(TransportError::WebSocket(format!("recv: {e}")));
932                }
933                None => {
934                    self.closed.store(true, Ordering::Release);
935                    return Ok(None);
936                }
937            }
938        }
939    }
940
941    async fn close(&mut self) -> Result<(), TransportError> {
942        // Abort heartbeat task
943        if let Some(handle) = self.heartbeat_handle.take() {
944            handle.abort();
945        }
946
947        self.closed.store(true, Ordering::Release);
948
949        let mut w = self.write.lock().await;
950        w.close()
951            .await
952            .map_err(|e| TransportError::WebSocket(format!("close: {e}")))
953    }
954
955    fn peer_addr(&self) -> String {
956        self.remote_addr.clone()
957    }
958}
959
960impl Drop for WsFramedStream {
961    fn drop(&mut self) {
962        // Ensure heartbeat task is cleaned up
963        if let Some(handle) = self.heartbeat_handle.take() {
964            handle.abort();
965        }
966    }
967}
968
969// ---------------------------------------------------------------------------
970// Unit tests
971// ---------------------------------------------------------------------------
972
973#[cfg(test)]
974mod unit_tests {
975    use super::*;
976
977    #[test]
978    fn resolve_dial_addr_prefers_ip() {
979        let addr = PeerAddr {
980            ip: Some("100.64.0.1".parse().unwrap()),
981            hostname: "peer".to_string(),
982            dns_name: Some("peer.tailnet.ts.net".to_string()),
983        };
984        assert_eq!(resolve_dial_addr(&addr), "100.64.0.1");
985    }
986
987    #[test]
988    fn resolve_dial_addr_falls_back_to_dns() {
989        let addr = PeerAddr {
990            ip: None,
991            hostname: "peer".to_string(),
992            dns_name: Some("peer.tailnet.ts.net".to_string()),
993        };
994        assert_eq!(resolve_dial_addr(&addr), "peer.tailnet.ts.net");
995    }
996
997    #[test]
998    fn resolve_dial_addr_falls_back_to_hostname() {
999        let addr = PeerAddr {
1000            ip: None,
1001            hostname: "peer".to_string(),
1002            dns_name: None,
1003        };
1004        assert_eq!(resolve_dial_addr(&addr), "peer");
1005    }
1006
1007    // ── RFC 017 fix G: length-bound each hello identity field ─────────
1008
1009    fn valid_identity() -> crate::session::hello::PeerIdentity {
1010        crate::session::hello::PeerIdentity {
1011            app_id: "playground".to_string(),
1012            device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".to_string(),
1013            device_name: "Alice's MacBook".to_string(),
1014            os: "darwin".to_string(),
1015            tailscale_id: "n1234567890.ts-node".to_string(),
1016        }
1017    }
1018
1019    #[test]
1020    fn validate_hello_accepts_normal_identity() {
1021        let envelope = crate::session::hello::HelloEnvelope::new(valid_identity());
1022        let result = validate_hello(envelope, "playground");
1023        assert!(result.is_ok(), "baseline valid hello should validate");
1024    }
1025
1026    #[test]
1027    fn validate_hello_rejects_device_id_equal_to_tailscale_id() {
1028        // RFC 022 I1 at the trust boundary: a hello claiming its Tailscale id
1029        // as its durable device_id must be rejected before it can publish.
1030        let mut identity = valid_identity();
1031        identity.device_id = identity.tailscale_id.clone();
1032        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1033        match validate_hello(envelope, "playground") {
1034            Err(TransportError::HelloMalformed(msg)) => {
1035                assert!(
1036                    msg.contains("distinct from tailscale_id"),
1037                    "unexpected error: {msg}"
1038                );
1039            }
1040            other => panic!("expected HelloMalformed, got {other:?}"),
1041        }
1042    }
1043
1044    #[test]
1045    fn validate_hello_rejects_empty_device_id() {
1046        let mut identity = valid_identity();
1047        identity.device_id = String::new();
1048        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1049        match validate_hello(envelope, "playground") {
1050            Err(TransportError::HelloMalformed(msg)) => {
1051                assert!(msg.contains("non-empty"), "unexpected error: {msg}");
1052            }
1053            other => panic!("expected HelloMalformed, got {other:?}"),
1054        }
1055    }
1056
1057    #[test]
1058    fn validate_hello_rejects_oversized_app_id() {
1059        let mut identity = valid_identity();
1060        identity.app_id = "a".repeat(MAX_APP_ID_LEN + 1);
1061        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1062        let result = validate_hello(envelope, "playground");
1063        match result {
1064            Err(TransportError::HelloMalformed(msg)) => {
1065                assert!(
1066                    msg.contains("maximum allowed length"),
1067                    "unexpected error: {msg}"
1068                );
1069            }
1070            other => panic!("expected HelloMalformed, got {other:?}"),
1071        }
1072    }
1073
1074    #[test]
1075    fn validate_hello_rejects_oversized_device_name() {
1076        let mut identity = valid_identity();
1077        identity.device_name = "x".repeat(MAX_DEVICE_NAME_LEN + 1);
1078        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1079        let result = validate_hello(envelope, "playground");
1080        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
1081    }
1082
1083    #[test]
1084    fn validate_hello_rejects_oversized_device_id() {
1085        let mut identity = valid_identity();
1086        identity.device_id = "x".repeat(MAX_DEVICE_ID_LEN + 1);
1087        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1088        let result = validate_hello(envelope, "playground");
1089        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
1090    }
1091
1092    #[test]
1093    fn validate_hello_rejects_oversized_tailscale_id() {
1094        let mut identity = valid_identity();
1095        identity.tailscale_id = "x".repeat(MAX_TAILSCALE_ID_LEN + 1);
1096        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1097        let result = validate_hello(envelope, "playground");
1098        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
1099    }
1100
1101    #[test]
1102    fn validate_hello_rejects_oversized_os() {
1103        let mut identity = valid_identity();
1104        identity.os = "x".repeat(MAX_OS_LEN + 1);
1105        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1106        let result = validate_hello(envelope, "playground");
1107        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
1108    }
1109
1110    #[test]
1111    fn validate_hello_length_check_runs_before_app_id_mismatch() {
1112        // Even with a matching app_id, an oversized field still fails
1113        // with HelloMalformed (not AppMismatch). This is the
1114        // "oversized field on matched app_id" attack path from the
1115        // review.
1116        let mut identity = valid_identity();
1117        identity.device_name = "x".repeat(MAX_DEVICE_NAME_LEN + 1);
1118        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1119        let result = validate_hello(envelope, "playground");
1120        assert!(
1121            matches!(result, Err(TransportError::HelloMalformed(_))),
1122            "matched app_id must not let an oversized field through"
1123        );
1124    }
1125
1126    // ── RFC 017 §8: verify hello tailscale_id against WhoIs identity ───
1127
1128    #[test]
1129    fn verify_identity_rejects_mismatched_node_id() {
1130        // The hello claims tailscale_id "n1234567890.ts-node" but the
1131        // authenticated WhoIs identity says a different node — reject.
1132        let identity = valid_identity();
1133        let authenticated = r#"{"dnsName":"mallory.ts.net","nodeId":"real-node-id"}"#;
1134        match verify_authenticated_identity(&identity, authenticated) {
1135            Err(TransportError::IdentityMismatch {
1136                claimed,
1137                authenticated,
1138            }) => {
1139                assert_eq!(claimed, "n1234567890.ts-node");
1140                assert_eq!(authenticated, "real-node-id");
1141            }
1142            other => panic!("expected IdentityMismatch, got {other:?}"),
1143        }
1144    }
1145
1146    #[test]
1147    fn verify_identity_accepts_matching_node_id() {
1148        let identity = valid_identity();
1149        let authenticated = r#"{"dnsName":"alice.ts.net","nodeId":"n1234567890.ts-node"}"#;
1150        assert!(
1151            verify_authenticated_identity(&identity, authenticated).is_ok(),
1152            "matching nodeId must be accepted"
1153        );
1154    }
1155
1156    #[test]
1157    fn verify_identity_accepts_empty_authenticated_string() {
1158        // MockNetworkProvider fixtures and WhoIs failures deliver "" —
1159        // preserve the pre-fix warn-and-allow behavior.
1160        let identity = valid_identity();
1161        assert!(
1162            verify_authenticated_identity(&identity, "").is_ok(),
1163            "empty authenticated identity must fall open"
1164        );
1165    }
1166
1167    #[test]
1168    fn verify_identity_accepts_non_json_legacy_dns_value() {
1169        // The bridge header historically carried a plain DNS name; an
1170        // unparseable value falls open rather than rejecting.
1171        let identity = valid_identity();
1172        assert!(
1173            verify_authenticated_identity(&identity, "peer.tailnet.ts.net").is_ok(),
1174            "legacy plain-DNS-name authenticated value must fall open"
1175        );
1176    }
1177
1178    #[test]
1179    fn verify_identity_accepts_json_without_node_id() {
1180        // WhoIs returned no Node — the JSON parses but nodeId is absent.
1181        let identity = valid_identity();
1182        let authenticated = r#"{"dnsName":"peer.ts.net"}"#;
1183        assert!(
1184            verify_authenticated_identity(&identity, authenticated).is_ok(),
1185            "missing nodeId must fall open"
1186        );
1187    }
1188
1189    #[test]
1190    fn verify_identity_rejects_empty_claim_against_real_node_id() {
1191        // An empty claimed tailscale_id against a concrete authenticated
1192        // nodeId is a mismatch and is rejected (previously such a
1193        // connection registered under the useless key "").
1194        let mut identity = valid_identity();
1195        identity.tailscale_id = String::new();
1196        let authenticated = r#"{"nodeId":"real-node-id"}"#;
1197        match verify_authenticated_identity(&identity, authenticated) {
1198            Err(TransportError::IdentityMismatch {
1199                claimed,
1200                authenticated,
1201            }) => {
1202                assert_eq!(claimed, "");
1203                assert_eq!(authenticated, "real-node-id");
1204            }
1205            other => panic!("expected IdentityMismatch, got {other:?}"),
1206        }
1207    }
1208}