truffle_core/session/mod.rs
1//! Layer 5: Session — Peer identity, connection lifecycle, message routing.
2//!
3//! The [`PeerRegistry`] is the central component. It consumes peer discovery
4//! events from Layer 3 ([`NetworkProvider`]) and manages transport connections
5//! from Layer 4 ([`StreamTransport`], [`RawTransport`](crate::transport::RawTransport)).
6//!
7//! # Layer rules
8//!
9//! - Layer 5 does NOT know what the data means (no namespaces, no envelopes)
10//! - Layer 5 does NOT inspect payloads
11//! - Layer 5 does NOT do peer discovery — it consumes Layer 3 events
12//! - Peers exist because Layer 3 says they exist, NOT because of connections
13//! - Connections are lazy — established on first `send()`
14//! - Layer 5 does NOT implement any transport protocol — it delegates to Layer 4
15
16pub mod hello;
17pub mod reconnect;
18
19#[cfg(test)]
20mod tests;
21
22use std::collections::{HashMap, HashSet};
23use std::net::IpAddr;
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26
27use tokio::sync::{broadcast, mpsc, RwLock};
28
29pub use self::hello::{
30 HelloEnvelope, HelloKind, PeerIdentity, CLOSE_APP_MISMATCH, CLOSE_HELLO_PROTOCOL, HELLO_TIMEOUT,
31};
32use self::reconnect::ReconnectBackoff;
33
34use crate::network::{NetworkPeer, NetworkPeerEvent, NetworkProvider, PeerAddr};
35use crate::transport::websocket::{WebSocketTransport, WsFramedStream};
36use crate::transport::{FramedStream, StreamTransport};
37
38// ---------------------------------------------------------------------------
39// Public types
40// ---------------------------------------------------------------------------
41
42/// A peer's state in the session registry.
43///
44/// Combines Layer 3 network information (discovery, addressing) with
45/// Layer 5 session state (connection status). Peers are added to the
46/// registry when Layer 3 reports them, NOT when transport connections
47/// are established.
48#[derive(Debug, Clone)]
49pub struct PeerState {
50 /// Tailscale stable node ID from the network provider. Used as the
51 /// primary key for routing inside the session layer.
52 pub id: String,
53 /// Tailscale hostname (as seen by Layer 3). This is the slugged form,
54 /// NOT the user-facing `device_name`.
55 pub name: String,
56 /// Network IP address.
57 pub ip: IpAddr,
58 /// Whether the peer is currently online (from Layer 3).
59 pub online: bool,
60 /// Whether the peer has an active WebSocket connection.
61 pub ws_connected: bool,
62 /// Connection type description (e.g., "direct" or "relay:ord").
63 pub connection_type: String,
64 /// Operating system of the peer, if known (from Layer 3).
65 pub os: Option<String>,
66 /// Last time the peer was seen online (RFC 3339 string).
67 pub last_seen: Option<String>,
68 /// Peer identity advertised in the remote's hello envelope (RFC 017 §8).
69 ///
70 /// `None` until the WebSocket hello handshake has completed; `Some`
71 /// once we have received the remote's identity block. This is the
72 /// source of truth for `device_id` and the display `device_name`.
73 pub identity: Option<PeerIdentity>,
74}
75
76/// Events emitted by the session layer when peer state changes.
77///
78/// Subscribers receive these via [`PeerRegistry::on_peer_change`].
79/// Events cover both Layer 3 discovery changes and Layer 5 connection
80/// lifecycle changes.
81#[derive(Debug, Clone)]
82pub enum PeerEvent {
83 /// A new peer appeared on the network (from Layer 3).
84 Joined(PeerState),
85 /// A peer left the network (by stable node ID, from Layer 3).
86 Left(String),
87 /// A peer's metadata changed (IP, relay, online status, from Layer 3).
88 Updated(PeerState),
89 /// A WebSocket connection was established to a peer (Layer 5 — WS transport).
90 WsConnected(String),
91 /// A WebSocket connection was lost to a peer (Layer 5 — WS transport).
92 WsDisconnected(String),
93 /// Authentication is required — the URL should be shown to the user.
94 AuthRequired { url: String },
95}
96
97/// An incoming message received from a peer via WebSocket.
98///
99/// Layer 5 does not inspect or interpret the data — it simply delivers
100/// raw bytes along with the sender's identity and a timestamp.
101#[derive(Debug, Clone)]
102pub struct IncomingMessage {
103 /// Sender's stable `device_id` (ULID) from the hello envelope.
104 /// RFC 017: application code always sees peer identity as `device_id`,
105 /// never as the Tailscale stable ID.
106 pub from: String,
107 /// Raw bytes received (Layer 6 will interpret this).
108 pub data: Vec<u8>,
109 /// When this message was received.
110 pub received_at: Instant,
111}
112
113// ---------------------------------------------------------------------------
114// Errors
115// ---------------------------------------------------------------------------
116
117/// Errors from Layer 5 session operations.
118#[derive(Debug, thiserror::Error)]
119pub enum SessionError {
120 /// The specified peer is not known to the registry.
121 #[error("unknown peer: {0}")]
122 UnknownPeer(String),
123
124 /// The specified peer is offline (Layer 3 reports not online).
125 #[error("peer offline: {0}")]
126 PeerOffline(String),
127
128 /// Failed to establish a transport connection.
129 #[error("connect failed: {0}")]
130 ConnectFailed(String),
131
132 /// Failed to send data on a transport connection.
133 #[error("send failed: {0}")]
134 SendFailed(String),
135
136 /// Reconnect backoff is active — wait before retrying.
137 #[error("reconnect backoff: retry after {retry_after:?}")]
138 ReconnectBackoff {
139 /// How long the caller must wait before retrying.
140 retry_after: Duration,
141 },
142
143 /// A transport layer error.
144 #[error("transport error: {0}")]
145 Transport(#[from] crate::transport::TransportError),
146}
147
148// ---------------------------------------------------------------------------
149// WsConnectionHandle — channel-based connection control
150// ---------------------------------------------------------------------------
151
152/// A handle to an active WebSocket connection.
153///
154/// Instead of sharing a `Mutex<WsFramedStream>` (which would deadlock
155/// because recv holds the lock across awaits), we use a channel pair:
156/// - `send_tx`: Send data to the connection task, which writes to the WS
157/// - `close_tx`: Signal the connection task to close and exit
158///
159/// The connection task exclusively owns the `WsFramedStream` and uses
160/// `tokio::select!` to multiplex between sending, receiving, and close
161/// signals. This avoids lock contention entirely.
162struct WsConnectionHandle {
163 /// Channel to send outgoing data to the connection task.
164 send_tx: mpsc::Sender<Vec<u8>>,
165 /// One-shot close signal. Dropping this also signals close.
166 close_tx: mpsc::Sender<()>,
167 /// Stable node ID of the connected peer.
168 #[allow(dead_code)]
169 peer_id: String,
170 /// When this connection was established.
171 #[allow(dead_code)]
172 connected_at: Instant,
173}
174
175// ---------------------------------------------------------------------------
176// PeerRegistry
177// ---------------------------------------------------------------------------
178
179/// Manages peer state and WebSocket connections.
180///
181/// The `PeerRegistry` is the heart of Layer 5. It:
182///
183/// 1. **Tracks peers** from Layer 3 discovery events — peers exist in the
184/// registry even with zero transport connections.
185/// 2. **Manages lazy connections** — the first [`send()`](Self::send) to a
186/// peer triggers a WebSocket connection via Layer 4. Subsequent sends
187/// reuse the cached connection.
188/// 3. **Routes messages** — incoming messages from any peer are forwarded
189/// to subscribers via a broadcast channel.
190/// 4. **Emits lifecycle events** — [`PeerEvent`]s for peer discovery changes
191/// and connection state changes.
192///
193/// # Example
194///
195/// ```ignore
196/// use std::sync::Arc;
197/// use truffle_core::session::PeerRegistry;
198///
199/// let registry = PeerRegistry::new(network, ws_transport);
200/// registry.start().await;
201///
202/// // Peers appear from Layer 3 discovery
203/// let peers = registry.peers().await;
204///
205/// // First send lazily connects
206/// registry.send("peer-id", b"hello").await?;
207/// ```
208pub struct PeerRegistry<N: NetworkProvider + 'static> {
209 /// Layer 3 network provider (for peer events and addressing).
210 network: Arc<N>,
211 /// Layer 4 WebSocket transport (for framed connections).
212 ws_transport: Arc<WebSocketTransport<N>>,
213
214 /// All known peers from Layer 3. Peers exist here even with zero connections.
215 peers: Arc<RwLock<HashMap<String, PeerState>>>,
216
217 /// Active WebSocket connection handles indexed by peer_id.
218 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
219
220 /// Reconnect backoff trackers per peer.
221 peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
222
223 /// Set of peer IDs currently being connected to (prevents duplicate dials).
224 connecting: Arc<RwLock<HashSet<String>>>,
225
226 /// Event channel for peer changes (discovery + connection lifecycle).
227 event_tx: broadcast::Sender<PeerEvent>,
228
229 /// Channel for incoming messages from any connected peer.
230 incoming_tx: broadcast::Sender<IncomingMessage>,
231}
232
233impl<N: NetworkProvider + 'static> PeerRegistry<N> {
234 /// Create a new peer registry.
235 ///
236 /// - `network`: The Layer 3 network provider for peer discovery.
237 /// - `ws_transport`: The Layer 4 WebSocket transport for connections.
238 ///
239 /// Call [`start()`](Self::start) after creation to begin processing
240 /// peer events and accepting incoming connections.
241 pub fn new(network: Arc<N>, ws_transport: Arc<WebSocketTransport<N>>) -> Self {
242 let (event_tx, _) = broadcast::channel(256);
243 let (incoming_tx, _) = broadcast::channel(1024);
244
245 Self {
246 network,
247 ws_transport,
248 peers: Arc::new(RwLock::new(HashMap::new())),
249 ws_connections: Arc::new(RwLock::new(HashMap::new())),
250 peer_backoffs: Arc::new(RwLock::new(HashMap::new())),
251 connecting: Arc::new(RwLock::new(HashSet::new())),
252 event_tx,
253 incoming_tx,
254 }
255 }
256
257 /// Start the peer registry.
258 ///
259 /// This spawns two background tasks:
260 /// 1. A task that subscribes to Layer 3 peer events and maintains the
261 /// peer list (Joined/Left/Updated).
262 /// 2. A task that listens for incoming WebSocket connections from peers
263 /// and spawns connection tasks for each.
264 ///
265 /// Call this once after constructing the registry.
266 pub async fn start(&self) {
267 // Task 1: Subscribe to Layer 3 peer events
268 self.spawn_peer_event_loop();
269
270 // Task 2: Accept incoming WS connections
271 self.spawn_accept_loop().await;
272 }
273
274 /// Spawn a task that subscribes to Layer 3 peer events and updates the
275 /// internal peer list.
276 fn spawn_peer_event_loop(&self) {
277 let mut events = self.network.peer_events();
278 let peers = self.peers.clone();
279 let ws_connections = self.ws_connections.clone();
280 let event_tx = self.event_tx.clone();
281
282 tokio::spawn(async move {
283 loop {
284 match events.recv().await {
285 Ok(NetworkPeerEvent::Joined(network_peer)) => {
286 let state = network_peer_to_state(&network_peer);
287 let peer_event = PeerEvent::Joined(state.clone());
288
289 {
290 let mut map = peers.write().await;
291 map.insert(network_peer.id.clone(), state);
292 }
293
294 let _ = event_tx.send(peer_event);
295 tracing::info!(
296 peer_id = %network_peer.id,
297 peer_name = %network_peer.hostname,
298 "session: peer joined"
299 );
300 }
301 Ok(NetworkPeerEvent::Left(peer_id)) => {
302 // Close any active WS connection for this peer
303 let handle = {
304 let mut conns = ws_connections.write().await;
305 conns.remove(&peer_id)
306 };
307 if let Some(handle) = handle {
308 let _ = handle.close_tx.send(()).await;
309 // Emit Disconnected before Left
310 let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id.clone()));
311 tracing::info!(
312 peer_id = %peer_id,
313 "session: closed WS connection for departing peer"
314 );
315 }
316
317 {
318 let mut map = peers.write().await;
319 map.remove(&peer_id);
320 }
321
322 let _ = event_tx.send(PeerEvent::Left(peer_id.clone()));
323 tracing::info!(peer_id = %peer_id, "session: peer left");
324 }
325 Ok(NetworkPeerEvent::Updated(network_peer)) => {
326 let mut state = network_peer_to_state(&network_peer);
327
328 // Preserve Layer 5 state (ws_connected, identity)
329 // from the existing entry — Layer 3 Updated events
330 // only carry discovery metadata, not connection or
331 // hello state.
332 {
333 let mut map = peers.write().await;
334 if let Some(existing) = map.get(&network_peer.id) {
335 state.ws_connected = existing.ws_connected;
336 state.identity = existing.identity.clone();
337 }
338 map.insert(network_peer.id.clone(), state.clone());
339 }
340
341 let _ = event_tx.send(PeerEvent::Updated(state));
342 tracing::debug!(
343 peer_id = %network_peer.id,
344 "session: peer updated"
345 );
346 }
347 Ok(NetworkPeerEvent::AuthRequired { url }) => {
348 let _ = event_tx.send(PeerEvent::AuthRequired { url });
349 }
350 Err(broadcast::error::RecvError::Lagged(n)) => {
351 tracing::warn!(
352 missed = n,
353 "session: peer event receiver lagged, missed {n} events"
354 );
355 }
356 Err(broadcast::error::RecvError::Closed) => {
357 tracing::debug!("session: peer event channel closed");
358 break;
359 }
360 }
361 }
362 });
363 }
364
365 /// Spawn a task that accepts incoming WebSocket connections from peers.
366 async fn spawn_accept_loop(&self) {
367 let ws_transport = self.ws_transport.clone();
368 let ws_connections = self.ws_connections.clone();
369 let peers = self.peers.clone();
370 let event_tx = self.event_tx.clone();
371 let incoming_tx = self.incoming_tx.clone();
372
373 // Try to start the WS listener. If it fails, log and return.
374 let mut listener = match ws_transport.listen().await {
375 Ok(l) => l,
376 Err(e) => {
377 tracing::error!("session: failed to start WS listener: {e}");
378 return;
379 }
380 };
381
382 tokio::spawn(async move {
383 loop {
384 match listener.accept().await {
385 Some(stream) => {
386 let peer_id = stream.remote_peer_id().to_string();
387 let remote_identity = stream.remote_identity().cloned();
388 tracing::info!(
389 peer_id = %peer_id,
390 device_id = remote_identity.as_ref().map(|i| i.device_id.as_str()),
391 "session: accepted incoming WS connection"
392 );
393
394 // Create connection handle and spawn connection task
395 let handle = spawn_connection_task(
396 stream,
397 peer_id.clone(),
398 ws_connections.clone(),
399 peers.clone(),
400 event_tx.clone(),
401 incoming_tx.clone(),
402 );
403
404 {
405 let mut conns = ws_connections.write().await;
406 conns.insert(peer_id.clone(), handle);
407 }
408
409 // Mark peer as connected and stamp identity from hello
410 {
411 let mut map = peers.write().await;
412 if let Some(state) = map.get_mut(&peer_id) {
413 state.ws_connected = true;
414 if remote_identity.is_some() {
415 state.identity = remote_identity.clone();
416 }
417 }
418 }
419
420 let _ = event_tx.send(PeerEvent::WsConnected(peer_id));
421 }
422 None => {
423 tracing::debug!("session: WS listener closed");
424 break;
425 }
426 }
427 }
428 });
429 }
430
431 /// Return all known peers.
432 ///
433 /// This returns peers discovered by Layer 3, including those with
434 /// no active transport connections (`ws_connected: false`).
435 pub async fn peers(&self) -> Vec<PeerState> {
436 let map = self.peers.read().await;
437 map.values().cloned().collect()
438 }
439
440 /// Subscribe to peer change events.
441 ///
442 /// Returns a broadcast receiver that yields [`PeerEvent`]s for peer
443 /// discovery changes (Joined/Left/Updated) and connection lifecycle
444 /// changes (Connected/Disconnected).
445 pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
446 self.event_tx.subscribe()
447 }
448
449 /// Send data to a specific peer.
450 ///
451 /// If no WebSocket connection exists to the peer, one is lazily
452 /// established via Layer 4. The connection is cached for subsequent
453 /// sends. If the peer is unknown or offline, an error is returned.
454 ///
455 /// # Errors
456 ///
457 /// - [`SessionError::UnknownPeer`] if the peer is not in the registry
458 /// - [`SessionError::PeerOffline`] if Layer 3 reports the peer as offline
459 /// - [`SessionError::ConnectFailed`] if the WS connection cannot be established
460 /// - [`SessionError::SendFailed`] if the send operation fails
461 pub async fn send(&self, peer_id: &str, data: &[u8]) -> Result<(), SessionError> {
462 // 0. Resolve peer_id to the session's internal routing key
463 // (the Tailscale stable node ID). Accepts any of:
464 // - Tailscale stable ID (direct match on `state.id`)
465 // - Device ID (ULID) from the hello envelope
466 // - Device name from the hello envelope
467 // - Layer 3 hostname (the sanitised slug)
468 let peer_id = {
469 let map = self.peers.read().await;
470 if map.contains_key(peer_id) {
471 peer_id.to_string()
472 } else {
473 map.values()
474 .find(|p| {
475 p.name == peer_id
476 || p.identity
477 .as_ref()
478 .map(|i| i.device_id == peer_id || i.device_name == peer_id)
479 .unwrap_or(false)
480 })
481 .map(|p| p.id.clone())
482 .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?
483 }
484 };
485 let peer_id = peer_id.as_str();
486
487 // 1. Look up peer in the registry
488 let peer_addr = {
489 let map = self.peers.read().await;
490 let state = map
491 .get(peer_id)
492 .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
493
494 if !state.online {
495 return Err(SessionError::PeerOffline(peer_id.to_string()));
496 }
497
498 PeerAddr {
499 ip: Some(state.ip),
500 hostname: state.name.clone(),
501 dns_name: None,
502 }
503 };
504
505 // 2. Check for existing WS connection
506 {
507 let conns = self.ws_connections.read().await;
508 if let Some(handle) = conns.get(peer_id) {
509 return handle
510 .send_tx
511 .send(data.to_vec())
512 .await
513 .map_err(|_| SessionError::SendFailed("connection task closed".to_string()));
514 }
515 }
516
517 // 3. Check reconnect backoff before attempting a new connection
518 {
519 let backoffs = self.peer_backoffs.read().await;
520 if let Some(backoff) = backoffs.get(peer_id) {
521 if backoff.should_retry().is_none() {
522 let retry_after = backoff.retry_after();
523 return Err(SessionError::ReconnectBackoff { retry_after });
524 }
525 }
526 }
527
528 // 4. Concurrent send protection — check if another task is already connecting
529 {
530 let mut connecting = self.connecting.write().await;
531 if connecting.contains(peer_id) {
532 // Another send() is already dialing this peer. Fail fast rather
533 // than creating a duplicate connection.
534 return Err(SessionError::ConnectFailed(
535 "connection already in progress".to_string(),
536 ));
537 }
538 connecting.insert(peer_id.to_string());
539 }
540
541 // 5. No existing connection — lazily connect via Layer 4
542 tracing::info!(peer_id = %peer_id, "session: lazy connecting WS");
543
544 let connect_result = self.ws_transport.connect(&peer_addr).await;
545
546 // Remove from connecting set regardless of outcome
547 {
548 let mut connecting = self.connecting.write().await;
549 connecting.remove(peer_id);
550 }
551
552 let ws_stream = match connect_result {
553 Ok(stream) => {
554 // Successful connect — reset backoff
555 let mut backoffs = self.peer_backoffs.write().await;
556 backoffs
557 .entry(peer_id.to_string())
558 .or_insert_with(ReconnectBackoff::new)
559 .success();
560 stream
561 }
562 Err(e) => {
563 // Failed connect — increase backoff
564 let mut backoffs = self.peer_backoffs.write().await;
565 backoffs
566 .entry(peer_id.to_string())
567 .or_insert_with(ReconnectBackoff::new)
568 .failure();
569 return Err(SessionError::ConnectFailed(e.to_string()));
570 }
571 };
572
573 // Capture the remote identity from the hello BEFORE moving the
574 // stream into the connection task.
575 let remote_identity = ws_stream.remote_identity().cloned();
576
577 // 6. Create connection handle and spawn connection task
578 let handle = spawn_connection_task(
579 ws_stream,
580 peer_id.to_string(),
581 self.ws_connections.clone(),
582 self.peers.clone(),
583 self.event_tx.clone(),
584 self.incoming_tx.clone(),
585 );
586
587 // Send data before inserting (so we don't lose the race)
588 let send_result = handle
589 .send_tx
590 .send(data.to_vec())
591 .await
592 .map_err(|_| SessionError::SendFailed("connection task closed".to_string()));
593
594 {
595 let mut conns = self.ws_connections.write().await;
596 conns.insert(peer_id.to_string(), handle);
597 }
598
599 // Mark peer as connected and stamp identity from hello
600 {
601 let mut map = self.peers.write().await;
602 if let Some(state) = map.get_mut(peer_id) {
603 state.ws_connected = true;
604 if remote_identity.is_some() {
605 state.identity = remote_identity;
606 }
607 }
608 }
609
610 let _ = self
611 .event_tx
612 .send(PeerEvent::WsConnected(peer_id.to_string()));
613
614 send_result
615 }
616
617 /// Broadcast data to all peers with active WebSocket connections.
618 ///
619 /// Sends to all currently connected peers. Peers with no active
620 /// connection are skipped (no lazy connect on broadcast).
621 /// Errors from individual sends are logged but do not fail the broadcast.
622 pub async fn broadcast(&self, data: &[u8]) {
623 let conns = self.ws_connections.read().await;
624
625 for (peer_id, handle) in conns.iter() {
626 if handle.send_tx.send(data.to_vec()).await.is_err() {
627 tracing::warn!(
628 peer_id = %peer_id,
629 "session: broadcast send failed (connection task closed)"
630 );
631 }
632 }
633 }
634
635 /// Subscribe to incoming messages from any connected peer.
636 ///
637 /// Returns a broadcast receiver that yields [`IncomingMessage`]s.
638 /// Messages include the sender's peer ID and raw bytes — Layer 5
639 /// does not interpret the payload.
640 pub fn subscribe(&self) -> broadcast::Receiver<IncomingMessage> {
641 self.incoming_tx.subscribe()
642 }
643
644 /// Test-only: stamp a synthetic [`PeerIdentity`] onto an existing
645 /// peer in the registry, simulating the effect of a completed hello
646 /// exchange without running a real WebSocket handshake. Returns
647 /// `true` if the peer was found and updated.
648 #[doc(hidden)]
649 pub async fn test_stamp_identity(&self, peer_id: &str, identity: PeerIdentity) -> bool {
650 let mut map = self.peers.write().await;
651 if let Some(state) = map.get_mut(peer_id) {
652 state.identity = Some(identity);
653 true
654 } else {
655 false
656 }
657 }
658
659 /// Disconnect a specific peer's WebSocket connection.
660 ///
661 /// Removes the cached connection and marks the peer as disconnected.
662 /// Does not remove the peer from the registry (that only happens when
663 /// Layer 3 emits a `Left` event).
664 pub async fn disconnect(&self, peer_id: &str) {
665 let handle = {
666 let mut conns = self.ws_connections.write().await;
667 conns.remove(peer_id)
668 };
669
670 if let Some(handle) = handle {
671 // Signal the connection task to close. If the channel is already
672 // closed (task exited), that's fine.
673 let _ = handle.close_tx.send(()).await;
674 }
675
676 // Mark peer as disconnected
677 {
678 let mut map = self.peers.write().await;
679 if let Some(state) = map.get_mut(peer_id) {
680 state.ws_connected = false;
681 }
682 }
683
684 let _ = self
685 .event_tx
686 .send(PeerEvent::WsDisconnected(peer_id.to_string()));
687 }
688
689 /// Close all active WebSocket connections and mark every peer as
690 /// disconnected. Called by `Node::stop()` during teardown. Safe to call
691 /// multiple times — with no active connections it is a no-op.
692 pub async fn shutdown(&self) {
693 let handles: Vec<(String, WsConnectionHandle)> = {
694 let mut conns = self.ws_connections.write().await;
695 conns.drain().collect()
696 };
697 for (peer_id, handle) in handles {
698 let _ = handle.close_tx.send(()).await;
699 let _ = self.event_tx.send(PeerEvent::WsDisconnected(peer_id));
700 }
701 let mut map = self.peers.write().await;
702 for state in map.values_mut() {
703 state.ws_connected = false;
704 }
705 }
706}
707
708// ---------------------------------------------------------------------------
709// Connection task — exclusively owns the WsFramedStream
710// ---------------------------------------------------------------------------
711
712/// Spawn a background task that exclusively owns a `WsFramedStream`.
713///
714/// The task uses `tokio::select!` to multiplex between:
715/// - Receiving outgoing data from the `send_rx` channel and writing to the WS
716/// - Reading incoming data from the WS and forwarding to `incoming_tx`
717/// - Receiving a close signal from `close_rx`
718///
719/// When the task exits (stream closed, error, or close signal), it cleans up
720/// the connection from the registry and emits a `Disconnected` event.
721///
722/// Returns a [`WsConnectionHandle`] for the caller to send data and close.
723fn spawn_connection_task(
724 stream: WsFramedStream,
725 peer_id: String,
726 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
727 peers: Arc<RwLock<HashMap<String, PeerState>>>,
728 event_tx: broadcast::Sender<PeerEvent>,
729 incoming_tx: broadcast::Sender<IncomingMessage>,
730) -> WsConnectionHandle {
731 let (send_tx, mut send_rx) = mpsc::channel::<Vec<u8>>(256);
732 let (close_tx, mut close_rx) = mpsc::channel::<()>(1);
733
734 let handle = WsConnectionHandle {
735 send_tx: send_tx.clone(),
736 close_tx: close_tx.clone(),
737 peer_id: peer_id.clone(),
738 connected_at: Instant::now(),
739 };
740
741 // Resolve the sender identifier the session will stamp on every
742 // inbound message: RFC 017 says this is the remote's `device_id`
743 // from the hello envelope (not the Tailscale stable ID). Fall back
744 // to the routing key if we somehow received a stream without a
745 // hello — in production that should never happen because the hello
746 // exchange runs before this function is called.
747 let from_device_id = stream
748 .remote_identity()
749 .map(|i| i.device_id.clone())
750 .unwrap_or_else(|| peer_id.clone());
751
752 tokio::spawn(async move {
753 let mut stream = stream;
754 let mut closed = false;
755
756 loop {
757 tokio::select! {
758 // Outgoing: data from send channel → write to WS
759 Some(data) = send_rx.recv() => {
760 if let Err(e) = stream.send(&data).await {
761 tracing::warn!(
762 peer_id = %peer_id,
763 error = %e,
764 "session: WS send error"
765 );
766 break;
767 }
768 }
769
770 // Incoming: data from WS → forward to incoming channel
771 result = stream.recv() => {
772 match result {
773 Ok(Some(data)) => {
774 let msg = IncomingMessage {
775 from: from_device_id.clone(),
776 data,
777 received_at: Instant::now(),
778 };
779 let _ = incoming_tx.send(msg);
780 }
781 Ok(None) => {
782 tracing::info!(
783 peer_id = %peer_id,
784 "session: WS stream closed"
785 );
786 break;
787 }
788 Err(e) => {
789 tracing::warn!(
790 peer_id = %peer_id,
791 error = %e,
792 "session: WS recv error"
793 );
794 break;
795 }
796 }
797 }
798
799 // Close signal
800 _ = close_rx.recv() => {
801 tracing::info!(
802 peer_id = %peer_id,
803 "session: connection close requested"
804 );
805 closed = true;
806 let _ = stream.close().await;
807 break;
808 }
809 }
810 }
811
812 // Clean up: remove connection from registry, mark peer as disconnected
813 // Only clean up if we weren't explicitly closed (disconnect() handles
814 // its own cleanup to avoid racing).
815 if !closed {
816 {
817 let mut conns = ws_connections.write().await;
818 conns.remove(&peer_id);
819 }
820 {
821 let mut map = peers.write().await;
822 if let Some(state) = map.get_mut(&peer_id) {
823 state.ws_connected = false;
824 }
825 }
826 let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id));
827 }
828 });
829
830 handle
831}
832
833// ---------------------------------------------------------------------------
834// Helper: convert NetworkPeer to PeerState
835// ---------------------------------------------------------------------------
836
837/// Convert a Layer 3 `NetworkPeer` to a Layer 5 `PeerState`.
838///
839/// Sets `ws_connected: false` by default — connections are managed by Layer 5,
840/// not by Layer 3 discovery.
841fn network_peer_to_state(peer: &NetworkPeer) -> PeerState {
842 let connection_type = if let Some(ref relay) = peer.relay {
843 format!("relay:{relay}")
844 } else if peer.cur_addr.is_some() {
845 "direct".to_string()
846 } else {
847 "unknown".to_string()
848 };
849
850 PeerState {
851 id: peer.id.clone(),
852 name: peer.hostname.clone(),
853 ip: peer.ip,
854 online: peer.online,
855 ws_connected: false,
856 connection_type,
857 os: peer.os.clone(),
858 last_seen: peer.last_seen.clone(),
859 identity: None,
860 }
861}