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`]).
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 let Err(_) = handle.send_tx.send(data.to_vec()).await {
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
690// ---------------------------------------------------------------------------
691// Connection task — exclusively owns the WsFramedStream
692// ---------------------------------------------------------------------------
693
694/// Spawn a background task that exclusively owns a `WsFramedStream`.
695///
696/// The task uses `tokio::select!` to multiplex between:
697/// - Receiving outgoing data from the `send_rx` channel and writing to the WS
698/// - Reading incoming data from the WS and forwarding to `incoming_tx`
699/// - Receiving a close signal from `close_rx`
700///
701/// When the task exits (stream closed, error, or close signal), it cleans up
702/// the connection from the registry and emits a `Disconnected` event.
703///
704/// Returns a [`WsConnectionHandle`] for the caller to send data and close.
705fn spawn_connection_task(
706 stream: WsFramedStream,
707 peer_id: String,
708 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
709 peers: Arc<RwLock<HashMap<String, PeerState>>>,
710 event_tx: broadcast::Sender<PeerEvent>,
711 incoming_tx: broadcast::Sender<IncomingMessage>,
712) -> WsConnectionHandle {
713 let (send_tx, mut send_rx) = mpsc::channel::<Vec<u8>>(256);
714 let (close_tx, mut close_rx) = mpsc::channel::<()>(1);
715
716 let handle = WsConnectionHandle {
717 send_tx: send_tx.clone(),
718 close_tx: close_tx.clone(),
719 peer_id: peer_id.clone(),
720 connected_at: Instant::now(),
721 };
722
723 // Resolve the sender identifier the session will stamp on every
724 // inbound message: RFC 017 says this is the remote's `device_id`
725 // from the hello envelope (not the Tailscale stable ID). Fall back
726 // to the routing key if we somehow received a stream without a
727 // hello — in production that should never happen because the hello
728 // exchange runs before this function is called.
729 let from_device_id = stream
730 .remote_identity()
731 .map(|i| i.device_id.clone())
732 .unwrap_or_else(|| peer_id.clone());
733
734 tokio::spawn(async move {
735 let mut stream = stream;
736 let mut closed = false;
737
738 loop {
739 tokio::select! {
740 // Outgoing: data from send channel → write to WS
741 Some(data) = send_rx.recv() => {
742 if let Err(e) = stream.send(&data).await {
743 tracing::warn!(
744 peer_id = %peer_id,
745 error = %e,
746 "session: WS send error"
747 );
748 break;
749 }
750 }
751
752 // Incoming: data from WS → forward to incoming channel
753 result = stream.recv() => {
754 match result {
755 Ok(Some(data)) => {
756 let msg = IncomingMessage {
757 from: from_device_id.clone(),
758 data,
759 received_at: Instant::now(),
760 };
761 let _ = incoming_tx.send(msg);
762 }
763 Ok(None) => {
764 tracing::info!(
765 peer_id = %peer_id,
766 "session: WS stream closed"
767 );
768 break;
769 }
770 Err(e) => {
771 tracing::warn!(
772 peer_id = %peer_id,
773 error = %e,
774 "session: WS recv error"
775 );
776 break;
777 }
778 }
779 }
780
781 // Close signal
782 _ = close_rx.recv() => {
783 tracing::info!(
784 peer_id = %peer_id,
785 "session: connection close requested"
786 );
787 closed = true;
788 let _ = stream.close().await;
789 break;
790 }
791 }
792 }
793
794 // Clean up: remove connection from registry, mark peer as disconnected
795 // Only clean up if we weren't explicitly closed (disconnect() handles
796 // its own cleanup to avoid racing).
797 if !closed {
798 {
799 let mut conns = ws_connections.write().await;
800 conns.remove(&peer_id);
801 }
802 {
803 let mut map = peers.write().await;
804 if let Some(state) = map.get_mut(&peer_id) {
805 state.ws_connected = false;
806 }
807 }
808 let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id));
809 }
810 });
811
812 handle
813}
814
815// ---------------------------------------------------------------------------
816// Helper: convert NetworkPeer to PeerState
817// ---------------------------------------------------------------------------
818
819/// Convert a Layer 3 `NetworkPeer` to a Layer 5 `PeerState`.
820///
821/// Sets `ws_connected: false` by default — connections are managed by Layer 5,
822/// not by Layer 3 discovery.
823fn network_peer_to_state(peer: &NetworkPeer) -> PeerState {
824 let connection_type = if let Some(ref relay) = peer.relay {
825 format!("relay:{relay}")
826 } else if peer.cur_addr.is_some() {
827 "direct".to_string()
828 } else {
829 "unknown".to_string()
830 };
831
832 PeerState {
833 id: peer.id.clone(),
834 name: peer.hostname.clone(),
835 ip: peer.ip,
836 online: peer.online,
837 ws_connected: false,
838 connection_type,
839 os: peer.os.clone(),
840 last_seen: peer.last_seen.clone(),
841 identity: None,
842 }
843}