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    if remote.identity.app_id != local_app_id {
246        return Err(TransportError::AppMismatch {
247            local: local_app_id.to_string(),
248            remote: remote.identity.app_id.clone(),
249        });
250    }
251    Ok(remote.identity)
252}
253
254/// Subset of the sidecar's WhoIs-derived peer identity JSON
255/// (`peerIdentityData` in sidecar-slim) carried in the bridge header.
256#[derive(Debug, Default, serde::Deserialize)]
257struct AuthenticatedIdentity {
258    #[serde(default, rename = "nodeId")]
259    node_id: String,
260}
261
262/// Verify the hello's self-declared `tailscale_id` against the
263/// Tailscale-authenticated identity (WhoIs) the sidecar attached to the
264/// incoming connection (RFC 017 §8, close code 4003).
265///
266/// `authenticated` is the raw `remote_identity` string from the bridge header.
267/// The policy deliberately fails open when there is no trustworthy
268/// authenticated identity to compare against, so mock/loopback fixtures and
269/// WhoIs failures keep working as before, and only rejects a hello when the
270/// bridge gave us a concrete node ID that contradicts the claim:
271///
272/// - Empty `authenticated` → accept unverified (MockNetworkProvider, or the
273///   sidecar returned `""` because WhoIs failed).
274/// - Unparseable `authenticated` → accept unverified (legacy plain-DNS-name
275///   form of the bridge header field).
276/// - Parsed but empty `nodeId` → accept unverified (WhoIs returned no Node).
277/// - `nodeId == claimed.tailscale_id` → accept.
278/// - Otherwise → [`TransportError::IdentityMismatch`].
279fn verify_authenticated_identity(
280    claimed: &PeerIdentity,
281    authenticated: &str,
282) -> Result<(), TransportError> {
283    if authenticated.trim().is_empty() {
284        tracing::warn!(
285            claimed_tailscale_id = %claimed.tailscale_id,
286            "ws: no authenticated identity for incoming connection (mock/loopback or WhoIs failure); accepting hello claim unverified"
287        );
288        return Ok(());
289    }
290
291    let parsed = match serde_json::from_str::<AuthenticatedIdentity>(authenticated) {
292        Ok(parsed) => parsed,
293        Err(_) => {
294            tracing::warn!(
295                claimed_tailscale_id = %claimed.tailscale_id,
296                "ws: unparseable authenticated identity; accepting hello claim unverified"
297            );
298            return Ok(());
299        }
300    };
301
302    if parsed.node_id.is_empty() {
303        tracing::warn!(
304            claimed_tailscale_id = %claimed.tailscale_id,
305            "ws: authenticated identity carried no nodeId; accepting hello claim unverified"
306        );
307        return Ok(());
308    }
309
310    if parsed.node_id == claimed.tailscale_id {
311        Ok(())
312    } else {
313        Err(TransportError::IdentityMismatch {
314            claimed: claimed.tailscale_id.clone(),
315            authenticated: parsed.node_id,
316        })
317    }
318}
319
320/// Serialise and send a [`HelloEnvelope`] as a text frame.
321async fn send_hello(
322    ws: &mut WebSocketStream<TcpStream>,
323    hello: &HelloEnvelope,
324) -> Result<(), TransportError> {
325    let payload =
326        serde_json::to_string(hello).map_err(|e| TransportError::Serialize(e.to_string()))?;
327    ws.send(Message::Text(payload.into()))
328        .await
329        .map_err(|e| TransportError::HandshakeFailed(format!("send hello: {e}")))
330}
331
332/// Client-side hello exchange: send ours first, read theirs, validate, then
333/// return the remote identity. On error, close the socket with the matching
334/// RFC 017 close code before returning the error.
335async fn client_hello_exchange(
336    ws: &mut WebSocketStream<TcpStream>,
337    local_hello: &HelloEnvelope,
338) -> Result<PeerIdentity, TransportError> {
339    // Step 1: send our hello first — this is envelope zero.
340    if let Err(e) = send_hello(ws, local_hello).await {
341        close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "local send failed").await;
342        return Err(e);
343    }
344
345    // Step 2: read remote hello (timed).
346    let remote = match receive_hello(ws).await {
347        Ok(envelope) => envelope,
348        Err(e) => {
349            match &e {
350                TransportError::HelloTimeout => {
351                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "hello timeout").await;
352                }
353                TransportError::HelloMalformed(_) => {
354                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "malformed hello").await;
355                }
356                _ => {}
357            }
358            return Err(e);
359        }
360    };
361
362    // Step 3: validate against our expectations.
363    match validate_hello(remote, &local_hello.identity.app_id) {
364        Ok(identity) => Ok(identity),
365        Err(e) => {
366            match &e {
367                TransportError::AppMismatch { .. } => {
368                    close_ws_with_code(ws, CLOSE_APP_MISMATCH, "app mismatch").await;
369                }
370                TransportError::HelloMalformed(_) => {
371                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "bad hello").await;
372                }
373                _ => {}
374            }
375            Err(e)
376        }
377    }
378}
379
380/// Server-side hello exchange: read theirs first, validate, then send ours.
381/// On error, close the socket with the matching RFC 017 close code.
382///
383/// `authenticated_identity` is the bridge-provided WhoIs identity of the
384/// incoming connection ([`IncomingConnection::remote_identity`]). The claimed
385/// `tailscale_id` is verified against it (close code 4003) BEFORE we reveal our
386/// own hello, so an impersonator never learns our identity and no stream is
387/// yielded for a mismatched peer.
388async fn server_hello_exchange(
389    ws: &mut WebSocketStream<TcpStream>,
390    local_hello: &HelloEnvelope,
391    authenticated_identity: &str,
392) -> Result<PeerIdentity, TransportError> {
393    // Step 1: read remote hello (timed).
394    let remote = match receive_hello(ws).await {
395        Ok(envelope) => envelope,
396        Err(e) => {
397            match &e {
398                TransportError::HelloTimeout => {
399                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "hello timeout").await;
400                }
401                TransportError::HelloMalformed(_) => {
402                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "malformed hello").await;
403                }
404                _ => {}
405            }
406            return Err(e);
407        }
408    };
409
410    // Step 2: validate against our expectations.
411    let identity = match validate_hello(remote, &local_hello.identity.app_id) {
412        Ok(identity) => identity,
413        Err(e) => {
414            match &e {
415                TransportError::AppMismatch { .. } => {
416                    close_ws_with_code(ws, CLOSE_APP_MISMATCH, "app mismatch").await;
417                }
418                TransportError::HelloMalformed(_) => {
419                    close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "bad hello").await;
420                }
421                _ => {}
422            }
423            return Err(e);
424        }
425    };
426
427    // Step 2.5: verify the claimed tailscale_id against the Tailscale-
428    // authenticated identity BEFORE we reveal our own hello. Doing this
429    // before send_hello means an impersonator never receives our identity
430    // block and the stream is never yielded to the session layer.
431    if let Err(e) = verify_authenticated_identity(&identity, authenticated_identity) {
432        close_ws_with_code(ws, CLOSE_IDENTITY_MISMATCH, "identity mismatch").await;
433        return Err(e);
434    }
435
436    // Step 3: reply with our hello.
437    if let Err(e) = send_hello(ws, local_hello).await {
438        close_ws_with_code(ws, CLOSE_HELLO_PROTOCOL, "local send failed").await;
439        return Err(e);
440    }
441
442    Ok(identity)
443}
444
445impl<N: NetworkProvider + 'static> StreamTransport for WebSocketTransport<N> {
446    type Stream = WsFramedStream;
447
448    async fn connect(&self, addr: &PeerAddr) -> Result<Self::Stream, TransportError> {
449        // Step 1: Dial TCP via Layer 3
450        let dial_addr = resolve_dial_addr(addr);
451        tracing::debug!(addr = %dial_addr, port = self.config.port, "ws: dialing peer");
452
453        let tcp_stream = self
454            .network
455            .dial_tcp(&dial_addr, self.config.port)
456            .await
457            .map_err(|e| TransportError::ConnectFailed(format!("dial tcp: {e}")))?;
458
459        // Step 2: WebSocket client upgrade (with max_message_size config)
460        let ws_url = format!("ws://{dial_addr}:{}/ws", self.config.port);
461        let ws_config = self.ws_protocol_config();
462        let (mut ws, _response) =
463            tokio_tungstenite::client_async_with_config(ws_url, tcp_stream, Some(ws_config))
464                .await
465                .map_err(|e| TransportError::ConnectFailed(format!("ws upgrade: {e}")))?;
466
467        // Step 3: Hello exchange (RFC 017 §8)
468        let local_hello = self.local_hello();
469        let remote_identity = match client_hello_exchange(&mut ws, &local_hello).await {
470            Ok(identity) => identity,
471            Err(e) => {
472                match &e {
473                    TransportError::AppMismatch { local, remote } => {
474                        tracing::info!(
475                            local_app_id = %local,
476                            remote_app_id = %remote,
477                            "ws: closing connection — app_id mismatch"
478                        );
479                    }
480                    TransportError::HelloTimeout => {
481                        tracing::warn!("ws: hello timeout on outgoing connection");
482                    }
483                    TransportError::HelloMalformed(msg) => {
484                        tracing::warn!(error = %msg, "ws: malformed hello on outgoing connection");
485                    }
486                    _ => {}
487                }
488                return Err(e);
489            }
490        };
491
492        tracing::info!(
493            remote_device_id = %remote_identity.device_id,
494            remote_device_name = %remote_identity.device_name,
495            remote_tailscale_id = %remote_identity.tailscale_id,
496            "ws: connected (hello exchanged)"
497        );
498
499        // Step 4: Build framed stream with split read/write halves
500        Ok(WsFramedStream::new(
501            ws,
502            remote_identity.tailscale_id.clone(),
503            Some(remote_identity),
504            dial_addr,
505            self.config.ping_interval,
506            self.config.pong_timeout,
507        ))
508    }
509
510    async fn listen(&self) -> Result<StreamListener<Self::Stream>, TransportError> {
511        let port = self.config.port;
512        tracing::debug!(port, "ws: starting listener");
513
514        // Step 1: Listen on TCP via Layer 3
515        let mut tcp_listener = self
516            .network
517            .listen_tcp(port)
518            .await
519            .map_err(|e| TransportError::ListenFailed(format!("listen tcp: {e}")))?;
520
521        // Step 2: Spawn accept loop that upgrades each connection to WS
522        let (tx, rx) = tokio::sync::mpsc::channel::<WsFramedStream>(64);
523        let local_hello = self.local_hello();
524        let ping_interval = self.config.ping_interval;
525        let pong_timeout = self.config.pong_timeout;
526        let ws_config = self.ws_protocol_config();
527        let handshake_timeout = self.config.handshake_timeout;
528        let handshake_permits = Arc::new(tokio::sync::Semaphore::new(
529            self.config.max_pending_handshakes,
530        ));
531
532        tokio::spawn(async move {
533            loop {
534                match tcp_listener.incoming.recv().await {
535                    Some(incoming) => {
536                        // Bound in-flight handshakes: claim a permit before we
537                        // commit to upgrading this connection. Beyond the cap we
538                        // drop the connection pre-upgrade (`continue` drops
539                        // `incoming`, closing its TCP stream) — established
540                        // connections are never counted here.
541                        let permit = match handshake_permits.clone().try_acquire_owned() {
542                            Ok(permit) => permit,
543                            Err(_) => {
544                                tracing::warn!(
545                                    remote = %incoming.remote_addr,
546                                    "ws: max pending handshakes reached; dropping incoming connection"
547                                );
548                                continue;
549                            }
550                        };
551
552                        let tx = tx.clone();
553                        let local_hello = local_hello.clone();
554                        let remote_addr = incoming.remote_addr.clone();
555                        // Clone the bridge-provided WhoIs identity before
556                        // `incoming.stream` is moved into the WS upgrade below.
557                        let authenticated_identity = incoming.remote_identity.clone();
558                        let ws_config = ws_config;
559
560                        tokio::spawn(async move {
561                            // Run the WS upgrade + hello exchange under a single
562                            // timeout so a peer that stalls mid-upgrade (before
563                            // the 5s hello window even opens) can't pin the
564                            // handshake slot indefinitely.
565                            let handshake = async {
566                                let mut ws = tokio_tungstenite::accept_async_with_config(
567                                    incoming.stream,
568                                    Some(ws_config),
569                                )
570                                .await
571                                .map_err(|e| {
572                                    TransportError::HandshakeFailed(format!("ws upgrade: {e}"))
573                                })?;
574                                let identity = server_hello_exchange(
575                                    &mut ws,
576                                    &local_hello,
577                                    &authenticated_identity,
578                                )
579                                .await?;
580                                Ok::<(WebSocketStream<TcpStream>, PeerIdentity), TransportError>((
581                                    ws, identity,
582                                ))
583                            };
584
585                            let (ws, remote_identity) = match tokio::time::timeout(
586                                handshake_timeout,
587                                handshake,
588                            )
589                            .await
590                            {
591                                Ok(Ok(pair)) => pair,
592                                Ok(Err(e)) => {
593                                    match &e {
594                                        TransportError::AppMismatch { local, remote } => {
595                                            tracing::info!(
596                                                remote_addr = %remote_addr,
597                                                local_app_id = %local,
598                                                remote_app_id = %remote,
599                                                "ws: closing incoming connection — app_id mismatch"
600                                            );
601                                        }
602                                        TransportError::HelloTimeout => {
603                                            tracing::warn!(
604                                                remote_addr = %remote_addr,
605                                                "ws: hello timeout on incoming connection"
606                                            );
607                                        }
608                                        TransportError::HelloMalformed(msg) => {
609                                            tracing::warn!(
610                                                remote_addr = %remote_addr,
611                                                error = %msg,
612                                                "ws: malformed hello on incoming connection"
613                                            );
614                                        }
615                                        TransportError::IdentityMismatch {
616                                            claimed,
617                                            authenticated,
618                                        } => {
619                                            tracing::warn!(
620                                                remote_addr = %remote_addr,
621                                                claimed = %claimed,
622                                                authenticated = %authenticated,
623                                                "ws: closing incoming connection — hello identity does not match authenticated Tailscale identity"
624                                            );
625                                        }
626                                        TransportError::HandshakeFailed(msg) => {
627                                            tracing::warn!(
628                                                remote = %remote_addr,
629                                                error = %msg,
630                                                "ws: upgrade failed"
631                                            );
632                                        }
633                                        _ => {
634                                            tracing::warn!(
635                                                remote_addr = %remote_addr,
636                                                "ws: hello exchange failed: {e}"
637                                            );
638                                        }
639                                    }
640                                    return;
641                                }
642                                Err(_) => {
643                                    tracing::warn!(
644                                        remote_addr = %remote_addr,
645                                        timeout = ?handshake_timeout,
646                                        "ws: handshake timed out before hello; dropping connection"
647                                    );
648                                    return;
649                                }
650                            };
651
652                            // Release the handshake slot before handing the
653                            // established stream to the listener — the yielded
654                            // connection is no longer counted against the cap.
655                            drop(permit);
656
657                            tracing::info!(
658                                remote_device_id = %remote_identity.device_id,
659                                remote_device_name = %remote_identity.device_name,
660                                remote_tailscale_id = %remote_identity.tailscale_id,
661                                remote_addr = %remote_addr,
662                                "ws: accepted connection (hello exchanged)"
663                            );
664
665                            let stream = WsFramedStream::new(
666                                ws,
667                                remote_identity.tailscale_id.clone(),
668                                Some(remote_identity),
669                                remote_addr,
670                                ping_interval,
671                                pong_timeout,
672                            );
673
674                            if tx.send(stream).await.is_err() {
675                                tracing::debug!("ws: listener channel closed");
676                            }
677                        });
678                    }
679                    None => {
680                        tracing::debug!("ws: tcp listener channel closed");
681                        break;
682                    }
683                }
684            }
685        });
686
687        Ok(StreamListener::new(rx, port))
688    }
689}
690
691// ---------------------------------------------------------------------------
692// WsFramedStream
693// ---------------------------------------------------------------------------
694
695/// A WebSocket-backed [`FramedStream`].
696///
697/// Uses split read/write halves to avoid mutex contention between the
698/// heartbeat task and the main send/recv path:
699///
700/// - **Write half** (`SplitSink`): Shared via `Arc<Mutex<_>>` between
701///   `send()` and the heartbeat task (which sends Ping frames).
702/// - **Read half** (`SplitStream`): Owned exclusively by `recv()` — no
703///   mutex needed.
704/// - **Heartbeat**: Sends Ping on the write half at `ping_interval`. Tracks
705///   last Pong via `Arc<AtomicU64>` (epoch millis). If `last_pong` exceeds
706///   `pong_timeout`, the connection is closed.
707pub struct WsFramedStream {
708    /// Write half of the WebSocket stream, shared with heartbeat task.
709    write: Arc<Mutex<SplitSink<WebSocketStream<TcpStream>, Message>>>,
710    /// Read half of the WebSocket stream, owned exclusively by recv().
711    read: SplitStream<WebSocketStream<TcpStream>>,
712    /// Remote peer's Tailscale stable ID — the session layer routes by this.
713    /// Derived from the hello envelope's `identity.tailscale_id`.
714    remote_peer_id: String,
715    /// Remote peer identity from the hello envelope (RFC 017 §8).
716    remote_identity: Option<PeerIdentity>,
717    /// Remote address string.
718    remote_addr: String,
719    /// Handle to the heartbeat task (aborted on close/drop).
720    heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
721    /// Timestamp (epoch millis) of the last received Pong.
722    last_pong: Arc<AtomicU64>,
723    /// Flag indicating the connection has been closed.
724    closed: Arc<AtomicBool>,
725}
726
727impl std::fmt::Debug for WsFramedStream {
728    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
729        f.debug_struct("WsFramedStream")
730            .field("remote_peer_id", &self.remote_peer_id)
731            .field("remote_addr", &self.remote_addr)
732            .field("closed", &self.closed.load(Ordering::Relaxed))
733            .finish_non_exhaustive()
734    }
735}
736
737// SAFETY: All fields are Send. `SplitStream` is Send because
738// `WebSocketStream<TcpStream>` is Send. The Arc-wrapped fields are Sync.
739// We need the explicit Sync impl because `SplitStream` is not Sync,
740// but `WsFramedStream` is only accessed via `&mut self` (exclusive ref)
741// so Sync is safe.
742#[allow(unsafe_code)]
743unsafe impl Sync for WsFramedStream {}
744
745/// Get the current epoch time in milliseconds.
746fn epoch_millis() -> u64 {
747    std::time::SystemTime::now()
748        .duration_since(std::time::UNIX_EPOCH)
749        .unwrap_or_default()
750        .as_millis() as u64
751}
752
753impl WsFramedStream {
754    /// Create a new framed stream with split read/write halves and heartbeat.
755    fn new(
756        ws: WebSocketStream<TcpStream>,
757        remote_peer_id: String,
758        remote_identity: Option<PeerIdentity>,
759        remote_addr: String,
760        ping_interval: Duration,
761        pong_timeout: Duration,
762    ) -> Self {
763        let (write, read) = ws.split();
764        let write = Arc::new(Mutex::new(write));
765        let last_pong = Arc::new(AtomicU64::new(epoch_millis()));
766        let closed = Arc::new(AtomicBool::new(false));
767
768        // Spawn heartbeat task
769        let hb_write = write.clone();
770        let hb_last_pong = last_pong.clone();
771        let hb_closed = closed.clone();
772        let hb_addr = remote_addr.clone();
773        let heartbeat_handle = tokio::spawn(async move {
774            heartbeat_loop(
775                hb_write,
776                hb_last_pong,
777                hb_closed,
778                ping_interval,
779                pong_timeout,
780                &hb_addr,
781            )
782            .await;
783        });
784
785        Self {
786            write,
787            read,
788            remote_peer_id,
789            remote_identity,
790            remote_addr,
791            heartbeat_handle: Some(heartbeat_handle),
792            last_pong,
793            closed,
794        }
795    }
796
797    /// Get the remote peer's Tailscale stable ID (from the hello envelope).
798    ///
799    /// This is the session layer's routing key. For the application-visible
800    /// device identifier, use [`remote_identity().device_id`](Self::remote_identity).
801    pub fn remote_peer_id(&self) -> &str {
802        &self.remote_peer_id
803    }
804
805    /// Get the full remote [`PeerIdentity`] advertised in the hello envelope.
806    ///
807    /// Returns `None` only in test fixtures that synthesise a stream without
808    /// running the hello exchange — production streams always have this.
809    pub fn remote_identity(&self) -> Option<&PeerIdentity> {
810        self.remote_identity.as_ref()
811    }
812}
813
814/// Background heartbeat loop that sends Ping frames and monitors Pong timestamps.
815///
816/// On each `ping_interval` tick:
817/// 1. Check if `last_pong` is within `pong_timeout` — if not, close.
818/// 2. Send a Ping frame on the write half.
819///
820/// This design avoids the old bug where `select!` between `ping_interval` (10s)
821/// and `pong_timeout` (30s) always picked the shorter timer, making timeout
822/// detection dead code.
823async fn heartbeat_loop(
824    write: Arc<Mutex<SplitSink<WebSocketStream<TcpStream>, Message>>>,
825    last_pong: Arc<AtomicU64>,
826    closed: Arc<AtomicBool>,
827    ping_interval: Duration,
828    pong_timeout: Duration,
829    remote_addr: &str,
830) {
831    let mut interval = tokio::time::interval(ping_interval);
832    // Skip the first immediate tick
833    interval.tick().await;
834
835    loop {
836        interval.tick().await;
837
838        // If the connection was closed externally, stop.
839        if closed.load(Ordering::Acquire) {
840            return;
841        }
842
843        // Check if last pong is within timeout
844        let last = last_pong.load(Ordering::Acquire);
845        let now = epoch_millis();
846        let elapsed = Duration::from_millis(now.saturating_sub(last));
847
848        if elapsed > pong_timeout {
849            tracing::warn!(
850                remote = %remote_addr,
851                elapsed = ?elapsed,
852                "heartbeat: pong timeout after {pong_timeout:?}"
853            );
854            // Close the connection
855            closed.store(true, Ordering::Release);
856            let mut w = write.lock().await;
857            let _ = w.close().await;
858            return;
859        }
860
861        // Send a ping
862        {
863            let mut w = write.lock().await;
864            let ping_data = b"truffle-ping".to_vec();
865            if let Err(e) = w.send(Message::Ping(ping_data.into())).await {
866                tracing::debug!(remote = %remote_addr, "heartbeat: ping send failed: {e}");
867                closed.store(true, Ordering::Release);
868                return;
869            }
870        }
871    }
872}
873
874impl FramedStream for WsFramedStream {
875    async fn send(&mut self, data: &[u8]) -> Result<(), TransportError> {
876        if self.closed.load(Ordering::Acquire) {
877            return Err(TransportError::ConnectionClosed(
878                "connection already closed".to_string(),
879            ));
880        }
881        let mut w = self.write.lock().await;
882        w.send(Message::Binary(data.to_vec().into()))
883            .await
884            .map_err(|e| TransportError::WebSocket(format!("send: {e}")))
885    }
886
887    async fn recv(&mut self) -> Result<Option<Vec<u8>>, TransportError> {
888        if self.closed.load(Ordering::Acquire) {
889            return Ok(None);
890        }
891        loop {
892            match self.read.next().await {
893                Some(Ok(Message::Binary(data))) => return Ok(Some(data.to_vec())),
894                Some(Ok(Message::Text(text))) => {
895                    // Layer 4 treats text frames as binary data
896                    return Ok(Some(text.as_bytes().to_vec()));
897                }
898                Some(Ok(Message::Ping(_))) => {
899                    // We received a Ping — tungstenite auto-sends Pong at the
900                    // protocol level. Just skip and continue reading.
901                    continue;
902                }
903                Some(Ok(Message::Pong(_))) => {
904                    // Update last_pong timestamp for the heartbeat checker
905                    self.last_pong.store(epoch_millis(), Ordering::Release);
906                    continue;
907                }
908                Some(Ok(Message::Close(_))) => {
909                    self.closed.store(true, Ordering::Release);
910                    return Ok(None);
911                }
912                Some(Ok(Message::Frame(_))) => {
913                    // Raw frame — skip
914                    continue;
915                }
916                Some(Err(e)) => {
917                    self.closed.store(true, Ordering::Release);
918                    return Err(TransportError::WebSocket(format!("recv: {e}")));
919                }
920                None => {
921                    self.closed.store(true, Ordering::Release);
922                    return Ok(None);
923                }
924            }
925        }
926    }
927
928    async fn close(&mut self) -> Result<(), TransportError> {
929        // Abort heartbeat task
930        if let Some(handle) = self.heartbeat_handle.take() {
931            handle.abort();
932        }
933
934        self.closed.store(true, Ordering::Release);
935
936        let mut w = self.write.lock().await;
937        w.close()
938            .await
939            .map_err(|e| TransportError::WebSocket(format!("close: {e}")))
940    }
941
942    fn peer_addr(&self) -> String {
943        self.remote_addr.clone()
944    }
945}
946
947impl Drop for WsFramedStream {
948    fn drop(&mut self) {
949        // Ensure heartbeat task is cleaned up
950        if let Some(handle) = self.heartbeat_handle.take() {
951            handle.abort();
952        }
953    }
954}
955
956// ---------------------------------------------------------------------------
957// Unit tests
958// ---------------------------------------------------------------------------
959
960#[cfg(test)]
961mod unit_tests {
962    use super::*;
963
964    #[test]
965    fn resolve_dial_addr_prefers_ip() {
966        let addr = PeerAddr {
967            ip: Some("100.64.0.1".parse().unwrap()),
968            hostname: "peer".to_string(),
969            dns_name: Some("peer.tailnet.ts.net".to_string()),
970        };
971        assert_eq!(resolve_dial_addr(&addr), "100.64.0.1");
972    }
973
974    #[test]
975    fn resolve_dial_addr_falls_back_to_dns() {
976        let addr = PeerAddr {
977            ip: None,
978            hostname: "peer".to_string(),
979            dns_name: Some("peer.tailnet.ts.net".to_string()),
980        };
981        assert_eq!(resolve_dial_addr(&addr), "peer.tailnet.ts.net");
982    }
983
984    #[test]
985    fn resolve_dial_addr_falls_back_to_hostname() {
986        let addr = PeerAddr {
987            ip: None,
988            hostname: "peer".to_string(),
989            dns_name: None,
990        };
991        assert_eq!(resolve_dial_addr(&addr), "peer");
992    }
993
994    // ── RFC 017 fix G: length-bound each hello identity field ─────────
995
996    fn valid_identity() -> crate::session::hello::PeerIdentity {
997        crate::session::hello::PeerIdentity {
998            app_id: "playground".to_string(),
999            device_id: "01J4K9M2Z8AB3RNYQPW6H5TC0X".to_string(),
1000            device_name: "Alice's MacBook".to_string(),
1001            os: "darwin".to_string(),
1002            tailscale_id: "n1234567890.ts-node".to_string(),
1003        }
1004    }
1005
1006    #[test]
1007    fn validate_hello_accepts_normal_identity() {
1008        let envelope = crate::session::hello::HelloEnvelope::new(valid_identity());
1009        let result = validate_hello(envelope, "playground");
1010        assert!(result.is_ok(), "baseline valid hello should validate");
1011    }
1012
1013    #[test]
1014    fn validate_hello_rejects_oversized_app_id() {
1015        let mut identity = valid_identity();
1016        identity.app_id = "a".repeat(MAX_APP_ID_LEN + 1);
1017        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1018        let result = validate_hello(envelope, "playground");
1019        match result {
1020            Err(TransportError::HelloMalformed(msg)) => {
1021                assert!(
1022                    msg.contains("maximum allowed length"),
1023                    "unexpected error: {msg}"
1024                );
1025            }
1026            other => panic!("expected HelloMalformed, got {other:?}"),
1027        }
1028    }
1029
1030    #[test]
1031    fn validate_hello_rejects_oversized_device_name() {
1032        let mut identity = valid_identity();
1033        identity.device_name = "x".repeat(MAX_DEVICE_NAME_LEN + 1);
1034        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1035        let result = validate_hello(envelope, "playground");
1036        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
1037    }
1038
1039    #[test]
1040    fn validate_hello_rejects_oversized_device_id() {
1041        let mut identity = valid_identity();
1042        identity.device_id = "x".repeat(MAX_DEVICE_ID_LEN + 1);
1043        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1044        let result = validate_hello(envelope, "playground");
1045        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
1046    }
1047
1048    #[test]
1049    fn validate_hello_rejects_oversized_tailscale_id() {
1050        let mut identity = valid_identity();
1051        identity.tailscale_id = "x".repeat(MAX_TAILSCALE_ID_LEN + 1);
1052        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1053        let result = validate_hello(envelope, "playground");
1054        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
1055    }
1056
1057    #[test]
1058    fn validate_hello_rejects_oversized_os() {
1059        let mut identity = valid_identity();
1060        identity.os = "x".repeat(MAX_OS_LEN + 1);
1061        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1062        let result = validate_hello(envelope, "playground");
1063        assert!(matches!(result, Err(TransportError::HelloMalformed(_))));
1064    }
1065
1066    #[test]
1067    fn validate_hello_length_check_runs_before_app_id_mismatch() {
1068        // Even with a matching app_id, an oversized field still fails
1069        // with HelloMalformed (not AppMismatch). This is the
1070        // "oversized field on matched app_id" attack path from the
1071        // review.
1072        let mut identity = valid_identity();
1073        identity.device_name = "x".repeat(MAX_DEVICE_NAME_LEN + 1);
1074        let envelope = crate::session::hello::HelloEnvelope::new(identity);
1075        let result = validate_hello(envelope, "playground");
1076        assert!(
1077            matches!(result, Err(TransportError::HelloMalformed(_))),
1078            "matched app_id must not let an oversized field through"
1079        );
1080    }
1081
1082    // ── RFC 017 §8: verify hello tailscale_id against WhoIs identity ───
1083
1084    #[test]
1085    fn verify_identity_rejects_mismatched_node_id() {
1086        // The hello claims tailscale_id "n1234567890.ts-node" but the
1087        // authenticated WhoIs identity says a different node — reject.
1088        let identity = valid_identity();
1089        let authenticated = r#"{"dnsName":"mallory.ts.net","nodeId":"real-node-id"}"#;
1090        match verify_authenticated_identity(&identity, authenticated) {
1091            Err(TransportError::IdentityMismatch {
1092                claimed,
1093                authenticated,
1094            }) => {
1095                assert_eq!(claimed, "n1234567890.ts-node");
1096                assert_eq!(authenticated, "real-node-id");
1097            }
1098            other => panic!("expected IdentityMismatch, got {other:?}"),
1099        }
1100    }
1101
1102    #[test]
1103    fn verify_identity_accepts_matching_node_id() {
1104        let identity = valid_identity();
1105        let authenticated = r#"{"dnsName":"alice.ts.net","nodeId":"n1234567890.ts-node"}"#;
1106        assert!(
1107            verify_authenticated_identity(&identity, authenticated).is_ok(),
1108            "matching nodeId must be accepted"
1109        );
1110    }
1111
1112    #[test]
1113    fn verify_identity_accepts_empty_authenticated_string() {
1114        // MockNetworkProvider fixtures and WhoIs failures deliver "" —
1115        // preserve the pre-fix warn-and-allow behavior.
1116        let identity = valid_identity();
1117        assert!(
1118            verify_authenticated_identity(&identity, "").is_ok(),
1119            "empty authenticated identity must fall open"
1120        );
1121    }
1122
1123    #[test]
1124    fn verify_identity_accepts_non_json_legacy_dns_value() {
1125        // The bridge header historically carried a plain DNS name; an
1126        // unparseable value falls open rather than rejecting.
1127        let identity = valid_identity();
1128        assert!(
1129            verify_authenticated_identity(&identity, "peer.tailnet.ts.net").is_ok(),
1130            "legacy plain-DNS-name authenticated value must fall open"
1131        );
1132    }
1133
1134    #[test]
1135    fn verify_identity_accepts_json_without_node_id() {
1136        // WhoIs returned no Node — the JSON parses but nodeId is absent.
1137        let identity = valid_identity();
1138        let authenticated = r#"{"dnsName":"peer.ts.net"}"#;
1139        assert!(
1140            verify_authenticated_identity(&identity, authenticated).is_ok(),
1141            "missing nodeId must fall open"
1142        );
1143    }
1144
1145    #[test]
1146    fn verify_identity_rejects_empty_claim_against_real_node_id() {
1147        // An empty claimed tailscale_id against a concrete authenticated
1148        // nodeId is a mismatch and is rejected (previously such a
1149        // connection registered under the useless key "").
1150        let mut identity = valid_identity();
1151        identity.tailscale_id = String::new();
1152        let authenticated = r#"{"nodeId":"real-node-id"}"#;
1153        match verify_authenticated_identity(&identity, authenticated) {
1154            Err(TransportError::IdentityMismatch {
1155                claimed,
1156                authenticated,
1157            }) => {
1158                assert_eq!(claimed, "");
1159                assert_eq!(authenticated, "real-node-id");
1160            }
1161            other => panic!("expected IdentityMismatch, got {other:?}"),
1162        }
1163    }
1164}