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, Mutex as AsyncMutex, RwLock, Semaphore};
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///
49/// RFC 022: `id` is always the Tailscale stable node id (routing key).
50/// Application-facing durable identity lives in [`Self::identity`] and is
51/// projected as an honest `Option` (never filled with the Tailscale id).
52#[derive(Debug, Clone)]
53pub struct PeerState {
54 /// Tailscale stable node ID from the network provider. Used as the
55 /// primary key for routing inside the session layer.
56 pub id: String,
57 /// Generation counter for this `id` within this process (RFC 022 §7.7).
58 /// Bumped each time the same Tailscale node re-joins after `Left`.
59 /// Combined with `id` to form [`Self::peer_ref`].
60 pub generation: u64,
61 /// Tailscale hostname (as seen by Layer 3). This is the slugged form,
62 /// NOT the user-facing `device_name`.
63 pub name: String,
64 /// Network IP address.
65 pub ip: IpAddr,
66 /// Whether the peer is currently online (from Layer 3).
67 pub online: bool,
68 /// Whether the peer has an active WebSocket connection.
69 pub ws_connected: bool,
70 /// Connection type description (e.g., "direct" or "relay:ord").
71 pub connection_type: String,
72 /// Operating system of the peer, if known (from Layer 3).
73 pub os: Option<String>,
74 /// Last time the peer was seen online (RFC 3339 string).
75 pub last_seen: Option<String>,
76 /// Peer identity advertised in the remote's hello envelope (RFC 017 §8).
77 ///
78 /// `None` until identity is learned (hello / future hostinfo). This is
79 /// the source of truth for the durable ULID — never filled with the
80 /// Tailscale id as a fallback (RFC 022).
81 pub identity: Option<PeerIdentity>,
82 /// When true, `identity` is stored but **not published** as `device_id`
83 /// because another live peer already owns that ULID in `by_device`
84 /// (first-wins, RFC 022 §7.7).
85 pub identity_suppressed: bool,
86}
87
88impl PeerState {
89 /// Process-local peer ref: `{tailscale_id}:{generation}` (RFC 022).
90 pub fn peer_ref(&self) -> String {
91 format_peer_ref(&self.id, self.generation)
92 }
93
94 /// Published durable device id, if any (respects first-wins suppression).
95 pub fn published_device_id(&self) -> Option<&str> {
96 if self.identity_suppressed {
97 return None;
98 }
99 self.identity.as_ref().map(|i| i.device_id.as_str())
100 }
101}
102
103/// Format a [`PeerState::peer_ref`] / user-facing `Peer.peer_ref`.
104pub fn format_peer_ref(tailscale_id: &str, generation: u64) -> String {
105 format!("{tailscale_id}:{generation}")
106}
107
108/// Parse a `{tailscale_id}:{generation}` peer ref (RFC 022).
109///
110/// Returns `None` for anything else: exactly one `:`, non-empty id, all-digit
111/// generation. IPv6 literals (multiple colons) and colon-free identifiers
112/// never qualify, so query strings fall through to normal resolution.
113pub(crate) fn parse_peer_ref(s: &str) -> Option<(&str, u64)> {
114 let (ts, generation) = s.rsplit_once(':')?;
115 if ts.is_empty() || ts.contains(':') || generation.is_empty() {
116 return None;
117 }
118 if !generation.bytes().all(|b| b.is_ascii_digit()) {
119 return None;
120 }
121 Some((ts, generation.parse().ok()?))
122}
123
124/// Events emitted by the session layer when peer state changes.
125///
126/// Subscribers receive these via [`PeerRegistry::on_peer_change`].
127/// Events cover both Layer 3 discovery changes and Layer 5 connection
128/// lifecycle changes.
129#[derive(Debug, Clone)]
130pub enum PeerEvent {
131 /// A new peer appeared on the network (from Layer 3).
132 Joined(PeerState),
133 /// A peer left the network (from Layer 3). Carries the entry's **final
134 /// state** (marked offline, WS down) — the registry entry is already
135 /// gone when this fires, so consumers get a usable last view for
136 /// cleanup (RFC 022 §7.4 / §16.4) instead of a bare id.
137 Left(PeerState),
138 /// A peer's metadata changed (IP, relay, online status, from Layer 3).
139 Updated(PeerState),
140 /// Durable identity was first set or rotated on a peer (RFC 022).
141 /// Carries the full peer snapshot after the change.
142 Identity(PeerState),
143 /// A WebSocket connection was established to a peer (Layer 5 — WS transport).
144 /// Payload is the Tailscale stable id.
145 WsConnected(String),
146 /// A WebSocket connection was lost to a peer (Layer 5 — WS transport).
147 /// Payload is the Tailscale stable id. Does **not** clear learned identity.
148 WsDisconnected(String),
149 /// Authentication is required — the URL should be shown to the user.
150 AuthRequired { url: String },
151}
152
153/// Outcome of a broadcast.
154///
155/// "Queued" means the bytes were handed to a peer's connection task —
156/// delivery is not confirmed at this layer. Broadcasts reach only peers
157/// with an active WebSocket connection; no lazy connections are made.
158#[derive(Debug, Clone, Default)]
159pub struct BroadcastReport {
160 /// Peers with an active WS connection at broadcast time.
161 pub attempted: usize,
162 /// Messages successfully queued to a connection task.
163 pub queued: usize,
164 /// Tailscale ids of peers whose connection task was already closed.
165 pub failed: Vec<String>,
166}
167
168/// An incoming message received from a peer via WebSocket.
169///
170/// Layer 5 does not inspect or interpret the data — it simply delivers
171/// raw bytes along with the sender's identity and a timestamp.
172#[derive(Debug, Clone)]
173pub struct IncomingMessage {
174 /// Sender's **WhoIs-verified Tailscale stable id** (the connection's
175 /// routing key). RFC 022 §7.5: attribution never uses the self-declared
176 /// ULID from the hello envelope.
177 pub from: String,
178 /// Raw bytes received (Layer 6 will interpret this).
179 pub data: Vec<u8>,
180 /// When this message was received.
181 pub received_at: Instant,
182}
183
184// ---------------------------------------------------------------------------
185// Errors
186// ---------------------------------------------------------------------------
187
188/// Errors from Layer 5 session operations.
189#[derive(Debug, thiserror::Error)]
190pub enum SessionError {
191 /// The specified peer is not known to the registry.
192 #[error("unknown peer: {0}")]
193 UnknownPeer(String),
194
195 /// A peer-ref selector referenced a departed or superseded registry
196 /// entry generation (RFC 022 I5) — the handle it came from is stale.
197 #[error("peer gone: {0}")]
198 PeerGone(String),
199
200 /// The specified peer is offline (Layer 3 reports not online).
201 #[error("peer offline: {0}")]
202 PeerOffline(String),
203
204 /// Failed to establish a transport connection.
205 #[error("connect failed: {0}")]
206 ConnectFailed(String),
207
208 /// Failed to send data on a transport connection.
209 #[error("send failed: {0}")]
210 SendFailed(String),
211
212 /// Reconnect backoff is active — wait before retrying.
213 #[error("reconnect backoff: retry after {retry_after:?}")]
214 ReconnectBackoff {
215 /// How long the caller must wait before retrying.
216 retry_after: Duration,
217 },
218
219 /// A transport layer error.
220 #[error("transport error: {0}")]
221 Transport(#[from] crate::transport::TransportError),
222}
223
224// ---------------------------------------------------------------------------
225// WsConnectionHandle — channel-based connection control
226// ---------------------------------------------------------------------------
227
228/// A handle to an active WebSocket connection.
229///
230/// Instead of sharing a `Mutex<WsFramedStream>` (which would deadlock
231/// because recv holds the lock across awaits), we use a channel pair:
232/// - `send_tx`: Send data to the connection task, which writes to the WS
233/// - `close_tx`: Signal the connection task to close and exit
234///
235/// The connection task exclusively owns the `WsFramedStream` and uses
236/// `tokio::select!` to multiplex between sending, receiving, and close
237/// signals. This avoids lock contention entirely.
238struct WsConnectionHandle {
239 /// Channel to send outgoing data to the connection task.
240 send_tx: mpsc::Sender<Vec<u8>>,
241 /// One-shot close signal. Dropping this also signals close.
242 close_tx: mpsc::Sender<()>,
243 /// Stable node ID of the connected peer.
244 #[allow(dead_code)]
245 peer_id: String,
246 /// When this connection was established.
247 #[allow(dead_code)]
248 connected_at: Instant,
249}
250
251// ---------------------------------------------------------------------------
252// PeerRegistry
253// ---------------------------------------------------------------------------
254
255/// Manages peer state and WebSocket connections.
256///
257/// The `PeerRegistry` is the heart of Layer 5. It:
258///
259/// 1. **Tracks peers** from Layer 3 discovery events — peers exist in the
260/// registry even with zero transport connections.
261/// 2. **Manages lazy connections** — the first [`send()`](Self::send) to a
262/// peer triggers a WebSocket connection via Layer 4. Subsequent sends
263/// reuse the cached connection.
264/// 3. **Routes messages** — incoming messages from any peer are forwarded
265/// to subscribers via a broadcast channel.
266/// 4. **Emits lifecycle events** — [`PeerEvent`]s for peer discovery changes
267/// and connection state changes.
268///
269/// # Example
270///
271/// ```ignore
272/// use std::sync::Arc;
273/// use truffle_core::session::PeerRegistry;
274///
275/// let registry = PeerRegistry::new(network, ws_transport);
276/// registry.start().await;
277///
278/// // Peers appear from Layer 3 discovery
279/// let peers = registry.peers().await;
280///
281/// // First send lazily connects
282/// registry.send("peer-id", b"hello").await?;
283/// ```
284pub struct PeerRegistry<N: NetworkProvider + 'static> {
285 /// Layer 3 network provider (for peer events and addressing).
286 network: Arc<N>,
287 /// Layer 4 WebSocket transport (for framed connections).
288 ws_transport: Arc<WebSocketTransport<N>>,
289
290 /// All known peers from Layer 3, keyed by Tailscale stable id.
291 /// Peers exist here even with zero connections.
292 peers: Arc<RwLock<HashMap<String, PeerState>>>,
293
294 /// Durable ULID → Tailscale id for at most one **published** live entry
295 /// (RFC 022 §7.7). Used for queries only — never for message attribution.
296 by_device: Arc<RwLock<HashMap<String, String>>>,
297
298 /// Next generation number per Tailscale id (incremented on each join).
299 next_generation: Arc<RwLock<HashMap<String, u64>>>,
300
301 /// Active WebSocket connection handles indexed by peer_id (Tailscale id).
302 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
303
304 /// Reconnect backoff trackers per peer.
305 peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
306
307 /// Set of peer IDs currently being connected to (prevents duplicate dials).
308 connecting: Arc<RwLock<HashSet<String>>>,
309
310 /// Event channel for peer changes (discovery + connection lifecycle).
311 event_tx: broadcast::Sender<PeerEvent>,
312
313 /// Channel for incoming messages from any connected peer.
314 incoming_tx: broadcast::Sender<IncomingMessage>,
315
316 /// Handles to the registry's background loops (peer-event + WS accept),
317 /// retained so [`Self::shutdown`] can abort them instead of leaving
318 /// them running until every Arc clone drops.
319 background_tasks: std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>,
320
321 /// RFC 022 Phase C: proactively exchange hello with online peers.
322 eager_identity: bool,
323
324 /// Cap concurrent eager-identity dials (default 4).
325 eager_identity_sem: Arc<Semaphore>,
326
327 /// Per-peer jitter window (ms) applied before an eager dial (RFC 022 §8.1).
328 eager_identity_jitter_ms: u64,
329
330 /// Peer ids with an in-flight ensure_identity task (dedupe).
331 identity_inflight: Arc<AsyncMutex<HashSet<String>>>,
332}
333
334/// Options for [`PeerRegistry::with_options`].
335#[derive(Debug, Clone)]
336pub struct PeerRegistryOptions {
337 /// When true (default), dial the envelope WS once per online peer to
338 /// learn durable identity without waiting for app `send` (RFC 022 §8).
339 pub eager_identity: bool,
340 /// Max concurrent eager-identity dials. Default: 4.
341 pub eager_identity_concurrency: usize,
342 /// Max per-peer delay (ms) applied *before* each EAGER identity dial to
343 /// stagger the first burst of hellos when a node joins a large mesh
344 /// (RFC 022 §8.1). The delay is a deterministic hash of the peer id in
345 /// `0..eager_identity_jitter_ms` (truffle-core has no `rand` dependency).
346 /// `0` disables jitter — tests set it for deterministic timing. Only the
347 /// eager path is delayed; app `send` / `ensure_ws_connected` never wait.
348 /// Default: 250.
349 pub eager_identity_jitter_ms: u64,
350}
351
352impl Default for PeerRegistryOptions {
353 fn default() -> Self {
354 Self {
355 eager_identity: true,
356 eager_identity_concurrency: 4,
357 eager_identity_jitter_ms: 250,
358 }
359 }
360}
361
362impl<N: NetworkProvider + 'static> PeerRegistry<N> {
363 /// Create a new peer registry with default options (eager identity on).
364 ///
365 /// - `network`: The Layer 3 network provider for peer discovery.
366 /// - `ws_transport`: The Layer 4 WebSocket transport for connections.
367 ///
368 /// Call [`start()`](Self::start) after creation to begin processing
369 /// peer events and accepting incoming connections.
370 pub fn new(network: Arc<N>, ws_transport: Arc<WebSocketTransport<N>>) -> Self {
371 Self::with_options(network, ws_transport, PeerRegistryOptions::default())
372 }
373
374 /// Create a peer registry with explicit options (RFC 022 Phase C).
375 pub fn with_options(
376 network: Arc<N>,
377 ws_transport: Arc<WebSocketTransport<N>>,
378 options: PeerRegistryOptions,
379 ) -> Self {
380 let (event_tx, _) = broadcast::channel(256);
381 let (incoming_tx, _) = broadcast::channel(1024);
382 let concurrency = options.eager_identity_concurrency.max(1);
383
384 Self {
385 network,
386 ws_transport,
387 peers: Arc::new(RwLock::new(HashMap::new())),
388 by_device: Arc::new(RwLock::new(HashMap::new())),
389 next_generation: Arc::new(RwLock::new(HashMap::new())),
390 ws_connections: Arc::new(RwLock::new(HashMap::new())),
391 peer_backoffs: Arc::new(RwLock::new(HashMap::new())),
392 connecting: Arc::new(RwLock::new(HashSet::new())),
393 event_tx,
394 incoming_tx,
395 background_tasks: std::sync::Mutex::new(Vec::new()),
396 eager_identity: options.eager_identity,
397 eager_identity_sem: Arc::new(Semaphore::new(concurrency)),
398 eager_identity_jitter_ms: options.eager_identity_jitter_ms,
399 identity_inflight: Arc::new(AsyncMutex::new(HashSet::new())),
400 }
401 }
402
403 /// Start the peer registry.
404 ///
405 /// This spawns two background tasks:
406 /// 1. A task that subscribes to Layer 3 peer events and maintains the
407 /// peer list (Joined/Left/Updated).
408 /// 2. A task that listens for incoming WebSocket connections from peers
409 /// and spawns connection tasks for each.
410 ///
411 /// Call this once after constructing the registry.
412 pub async fn start(&self) {
413 // Task 1: Subscribe to Layer 3 peer events
414 self.spawn_peer_event_loop();
415
416 // Task 2: Accept incoming WS connections
417 self.spawn_accept_loop().await;
418 }
419
420 /// Spawn a task that subscribes to Layer 3 peer events and updates the
421 /// internal peer list.
422 fn spawn_peer_event_loop(&self) {
423 let mut events = self.network.peer_events();
424 let peers = self.peers.clone();
425 let by_device = self.by_device.clone();
426 let next_generation = self.next_generation.clone();
427 let ws_connections = self.ws_connections.clone();
428 let event_tx = self.event_tx.clone();
429 // Clones for scheduling eager identity from the event loop.
430 let schedule_ctx = EagerScheduleCtx {
431 eager_identity: self.eager_identity,
432 peers: self.peers.clone(),
433 by_device: self.by_device.clone(),
434 ws_connections: self.ws_connections.clone(),
435 peer_backoffs: self.peer_backoffs.clone(),
436 connecting: self.connecting.clone(),
437 event_tx: self.event_tx.clone(),
438 incoming_tx: self.incoming_tx.clone(),
439 ws_transport: self.ws_transport.clone(),
440 network: self.network.clone(),
441 eager_identity_sem: self.eager_identity_sem.clone(),
442 eager_identity_jitter_ms: self.eager_identity_jitter_ms,
443 identity_inflight: self.identity_inflight.clone(),
444 };
445
446 let handle = tokio::spawn(async move {
447 loop {
448 match events.recv().await {
449 Ok(NetworkPeerEvent::Joined(network_peer)) => {
450 let generation = {
451 let mut gens = next_generation.write().await;
452 let e = gens.entry(network_peer.id.clone()).or_insert(0);
453 *e += 1;
454 *e
455 };
456 let state = network_peer_to_state(&network_peer, generation);
457 let online = state.online;
458 let peer_id = network_peer.id.clone();
459 let peer_event = PeerEvent::Joined(state.clone());
460
461 {
462 let mut map = peers.write().await;
463 map.insert(network_peer.id.clone(), state);
464 }
465
466 let _ = event_tx.send(peer_event);
467 tracing::info!(
468 peer_id = %network_peer.id,
469 generation,
470 peer_name = %network_peer.hostname,
471 "session: peer joined"
472 );
473
474 if online {
475 schedule_ctx.schedule(peer_id);
476 }
477 }
478 Ok(NetworkPeerEvent::Left(peer_id)) => {
479 // Close any active WS connection for this peer
480 let handle = {
481 let mut conns = ws_connections.write().await;
482 conns.remove(&peer_id)
483 };
484 if let Some(handle) = handle {
485 let _ = handle.close_tx.send(()).await;
486 // Emit Disconnected before Left
487 let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id.clone()));
488 tracing::info!(
489 peer_id = %peer_id,
490 "session: closed WS connection for departing peer"
491 );
492 }
493
494 let removed = {
495 let mut map = peers.write().await;
496 map.remove(&peer_id)
497 };
498
499 // Drop by_device mapping if it pointed at this peer; promote
500 // a suppressed claimant if one exists (RFC 022 §7.7).
501 if let Some(mut removed) = removed {
502 let promote = {
503 let mut by_dev = by_device.write().await;
504 if let Some(uid) = removed.published_device_id() {
505 if by_dev.get(uid).map(|s| s.as_str()) == Some(peer_id.as_str())
506 {
507 by_dev.remove(uid);
508 Some(uid.to_string())
509 } else {
510 None
511 }
512 } else if let Some(ref ident) = removed.identity {
513 // Suppressed holder leaving — nothing published
514 let _ = ident;
515 None
516 } else {
517 None
518 }
519 };
520
521 if let Some(uid) = promote {
522 let mut map = peers.write().await;
523 let mut by_dev = by_device.write().await;
524 if let Some(promoted) = map.values_mut().find(|p| {
525 p.id != peer_id
526 && p.online
527 && p.identity
528 .as_ref()
529 .map(|i| i.device_id == uid)
530 .unwrap_or(false)
531 && p.identity_suppressed
532 }) {
533 promoted.identity_suppressed = false;
534 by_dev.insert(uid.clone(), promoted.id.clone());
535 let snap = promoted.clone();
536 drop(map);
537 drop(by_dev);
538 let _ = event_tx.send(PeerEvent::Identity(snap));
539 tracing::info!(
540 device_id = %uid,
541 "session: promoted suppressed ULID claimant after holder left"
542 );
543 }
544 }
545
546 // Emit `Left` with the entry's final view: the map
547 // entry is already gone, and consumers need a
548 // usable last state for cleanup (RFC 022 §16.4).
549 removed.online = false;
550 removed.ws_connected = false;
551 let _ = event_tx.send(PeerEvent::Left(removed));
552 tracing::info!(peer_id = %peer_id, "session: peer left");
553 } else {
554 // Never announced via `joined` — nothing to retire,
555 // so no `left` is emitted either.
556 tracing::debug!(
557 peer_id = %peer_id,
558 "session: left for unknown peer; no event"
559 );
560 }
561 }
562 Ok(NetworkPeerEvent::Updated(network_peer)) => {
563 let mut state = network_peer_to_state(&network_peer, 0);
564 // Both branches below assign this before it is read;
565 // no initializer keeps the unused-assignment lint quiet.
566 let became_online_without_identity;
567
568 // Preserve Layer 5 state (ws_connected, identity,
569 // generation, suppression) from the existing entry —
570 // Layer 3 Updated events only carry discovery metadata.
571 {
572 let mut map = peers.write().await;
573 if let Some(existing) = map.get(&network_peer.id) {
574 state.generation = existing.generation;
575 state.ws_connected = existing.ws_connected;
576 state.identity = existing.identity.clone();
577 state.identity_suppressed = existing.identity_suppressed;
578 became_online_without_identity = !existing.online
579 && state.online
580 && existing.published_device_id().is_none();
581 } else {
582 // Unknown peer Updated without Joined — treat as gen 1.
583 let mut gens = next_generation.write().await;
584 let e = gens.entry(network_peer.id.clone()).or_insert(0);
585 *e += 1;
586 state.generation = *e;
587 became_online_without_identity =
588 state.online && state.published_device_id().is_none();
589 }
590 map.insert(network_peer.id.clone(), state.clone());
591 }
592
593 let peer_id = network_peer.id.clone();
594 let _ = event_tx.send(PeerEvent::Updated(state));
595 tracing::debug!(
596 peer_id = %network_peer.id,
597 "session: peer updated"
598 );
599
600 if became_online_without_identity {
601 schedule_ctx.schedule(peer_id);
602 }
603 }
604 Ok(NetworkPeerEvent::AuthRequired { url }) => {
605 let _ = event_tx.send(PeerEvent::AuthRequired { url });
606 }
607 Err(broadcast::error::RecvError::Lagged(n)) => {
608 tracing::warn!(
609 missed = n,
610 "session: peer event receiver lagged, missed {n} events"
611 );
612 }
613 Err(broadcast::error::RecvError::Closed) => {
614 tracing::debug!("session: peer event channel closed");
615 break;
616 }
617 }
618 }
619 });
620 self.background_tasks.lock().unwrap().push(handle);
621 }
622
623 /// Spawn a task that accepts incoming WebSocket connections from peers.
624 async fn spawn_accept_loop(&self) {
625 let ws_transport = self.ws_transport.clone();
626 let ws_connections = self.ws_connections.clone();
627 let peers = self.peers.clone();
628 let by_device = self.by_device.clone();
629 let event_tx = self.event_tx.clone();
630 let incoming_tx = self.incoming_tx.clone();
631
632 // Try to start the WS listener. If it fails, log and return.
633 let mut listener = match ws_transport.listen().await {
634 Ok(l) => l,
635 Err(e) => {
636 tracing::error!("session: failed to start WS listener: {e}");
637 return;
638 }
639 };
640
641 let handle = tokio::spawn(async move {
642 loop {
643 match listener.accept().await {
644 Some(stream) => {
645 let peer_id = stream.remote_peer_id().to_string();
646 let remote_identity = stream.remote_identity().cloned();
647 tracing::info!(
648 peer_id = %peer_id,
649 device_id = remote_identity.as_ref().map(|i| i.device_id.as_str()),
650 "session: accepted incoming WS connection"
651 );
652
653 // Create connection handle and spawn connection task.
654 // Attribution uses WhoIs-verified Tailscale id (peer_id).
655 let handle = spawn_connection_task(
656 stream,
657 peer_id.clone(),
658 ws_connections.clone(),
659 peers.clone(),
660 event_tx.clone(),
661 incoming_tx.clone(),
662 );
663
664 {
665 let mut conns = ws_connections.write().await;
666 conns.insert(peer_id.clone(), handle);
667 }
668
669 // Mark peer as connected and apply identity from hello.
670 {
671 let mut map = peers.write().await;
672 let mut by_dev = by_device.write().await;
673 if let Some(state) = map.get_mut(&peer_id) {
674 state.ws_connected = true;
675 }
676 if let Some(identity) = remote_identity {
677 let outcomes =
678 apply_identity(&mut map, &mut by_dev, &peer_id, identity);
679 emit_identity_outcomes(&event_tx, outcomes);
680 }
681 }
682
683 let _ = event_tx.send(PeerEvent::WsConnected(peer_id));
684 }
685 None => {
686 tracing::debug!("session: WS listener closed");
687 break;
688 }
689 }
690 }
691 });
692 self.background_tasks.lock().unwrap().push(handle);
693 }
694
695 /// Return all known peers.
696 ///
697 /// This returns peers discovered by Layer 3, including those with
698 /// no active transport connections (`ws_connected: false`).
699 pub async fn peers(&self) -> Vec<PeerState> {
700 let map = self.peers.read().await;
701 map.values().cloned().collect()
702 }
703
704 /// Subscribe to peer change events.
705 ///
706 /// Returns a broadcast receiver that yields [`PeerEvent`]s for peer
707 /// discovery changes (Joined/Left/Updated) and connection lifecycle
708 /// changes (Connected/Disconnected).
709 pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
710 self.event_tx.subscribe()
711 }
712
713 /// Send data to a specific peer.
714 ///
715 /// If no WebSocket connection exists to the peer, one is lazily
716 /// established via Layer 4. The connection is cached for subsequent
717 /// sends. If the peer is unknown or offline, an error is returned.
718 ///
719 /// # Errors
720 ///
721 /// - [`SessionError::UnknownPeer`] if the peer is not in the registry
722 /// - [`SessionError::PeerOffline`] if Layer 3 reports the peer as offline
723 /// - [`SessionError::ConnectFailed`] if the WS connection cannot be established
724 /// - [`SessionError::SendFailed`] if the send operation fails
725 pub async fn send(&self, peer_id: &str, data: &[u8]) -> Result<(), SessionError> {
726 let peer_id = self.resolve_routing_key(peer_id).await?;
727 self.ensure_ws_connected(&peer_id).await?;
728
729 let conns = self.ws_connections.read().await;
730 let handle = conns
731 .get(&peer_id)
732 .ok_or_else(|| SessionError::SendFailed("connection missing after connect".into()))?;
733 handle
734 .send_tx
735 .send(data.to_vec())
736 .await
737 .map_err(|_| SessionError::SendFailed("connection task closed".to_string()))
738 }
739
740 /// Ensure a WS session exists to `peer_id` (Tailscale routing key).
741 /// Completes the RFC 017 hello and applies identity (RFC 022).
742 ///
743 /// Used by app `send` and by eager-identity (Phase C). Idempotent when
744 /// already connected.
745 pub async fn ensure_ws_connected(&self, peer_id: &str) -> Result<(), SessionError> {
746 // Already connected?
747 {
748 let conns = self.ws_connections.read().await;
749 if conns.contains_key(peer_id) {
750 return Ok(());
751 }
752 }
753
754 let peer_addr = {
755 let map = self.peers.read().await;
756 let state = map
757 .get(peer_id)
758 .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
759 if !state.online {
760 return Err(SessionError::PeerOffline(peer_id.to_string()));
761 }
762 PeerAddr {
763 ip: Some(state.ip),
764 hostname: state.name.clone(),
765 dns_name: None,
766 }
767 };
768
769 // Backoff
770 {
771 let backoffs = self.peer_backoffs.read().await;
772 if let Some(backoff) = backoffs.get(peer_id) {
773 if backoff.should_retry().is_none() {
774 let retry_after = backoff.retry_after();
775 return Err(SessionError::ReconnectBackoff { retry_after });
776 }
777 }
778 }
779
780 // Dedupe concurrent dials
781 {
782 let already = {
783 let connecting = self.connecting.read().await;
784 connecting.contains(peer_id)
785 };
786 if already {
787 // Wait briefly for the other dial to finish, then re-check.
788 for _ in 0..50 {
789 tokio::time::sleep(Duration::from_millis(20)).await;
790 let conns = self.ws_connections.read().await;
791 if conns.contains_key(peer_id) {
792 return Ok(());
793 }
794 let connecting = self.connecting.read().await;
795 if !connecting.contains(peer_id) {
796 break;
797 }
798 }
799 let conns = self.ws_connections.read().await;
800 if conns.contains_key(peer_id) {
801 return Ok(());
802 }
803 }
804 let mut connecting = self.connecting.write().await;
805 if connecting.contains(peer_id) {
806 return Err(SessionError::ConnectFailed(
807 "connection already in progress".to_string(),
808 ));
809 }
810 connecting.insert(peer_id.to_string());
811 }
812
813 tracing::info!(peer_id = %peer_id, "session: connecting WS");
814
815 let connect_result = self.ws_transport.connect(&peer_addr).await;
816
817 {
818 let mut connecting = self.connecting.write().await;
819 connecting.remove(peer_id);
820 }
821
822 let ws_stream = match connect_result {
823 Ok(stream) => {
824 let mut backoffs = self.peer_backoffs.write().await;
825 backoffs
826 .entry(peer_id.to_string())
827 .or_insert_with(ReconnectBackoff::new)
828 .success();
829 stream
830 }
831 Err(e) => {
832 let mut backoffs = self.peer_backoffs.write().await;
833 backoffs
834 .entry(peer_id.to_string())
835 .or_insert_with(ReconnectBackoff::new)
836 .failure();
837 return Err(SessionError::ConnectFailed(e.to_string()));
838 }
839 };
840
841 // RFC 022 §7.5, dial side: the answerer's claimed tailscale_id must
842 // match the peer this connection was dialed for. A mismatch means we
843 // reached something other than `peer_id` (port collision, loopback
844 // test rig, or a lying hello) — registering it would poison the
845 // entry's identity and route this peer's traffic to the answerer.
846 if let Some(claimed) = ws_stream.remote_identity() {
847 if claimed.tailscale_id != peer_id {
848 tracing::warn!(
849 peer_id = %peer_id,
850 claimed = %claimed.tailscale_id,
851 "session: dialed peer answered as a different tailscale_id; dropping connection"
852 );
853 return Err(SessionError::ConnectFailed(format!(
854 "hello identity mismatch: dialed {peer_id}, answerer claims {}",
855 claimed.tailscale_id
856 )));
857 }
858 }
859
860 let remote_identity = ws_stream.remote_identity().cloned();
861
862 let handle = spawn_connection_task(
863 ws_stream,
864 peer_id.to_string(),
865 self.ws_connections.clone(),
866 self.peers.clone(),
867 self.event_tx.clone(),
868 self.incoming_tx.clone(),
869 );
870
871 {
872 let mut conns = self.ws_connections.write().await;
873 conns.insert(peer_id.to_string(), handle);
874 }
875
876 {
877 let mut map = self.peers.write().await;
878 let mut by_dev = self.by_device.write().await;
879 if let Some(state) = map.get_mut(peer_id) {
880 state.ws_connected = true;
881 }
882 if let Some(identity) = remote_identity {
883 let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
884 emit_identity_outcomes(&self.event_tx, outcomes);
885 }
886 }
887
888 let _ = self
889 .event_tx
890 .send(PeerEvent::WsConnected(peer_id.to_string()));
891
892 Ok(())
893 }
894
895 /// One-shot identity exchange for an online peer (RFC 022 Phase C).
896 ///
897 /// No-ops if identity is already published or the peer is offline.
898 pub async fn ensure_identity(&self, peer_id: &str) -> Result<(), SessionError> {
899 {
900 let map = self.peers.read().await;
901 match map.get(peer_id) {
902 Some(s) if s.published_device_id().is_some() => return Ok(()),
903 Some(s) if !s.online => {
904 return Err(SessionError::PeerOffline(peer_id.to_string()));
905 }
906 None => return Err(SessionError::UnknownPeer(peer_id.to_string())),
907 _ => {}
908 }
909 }
910 self.ensure_ws_connected(peer_id).await
911 }
912
913 async fn resolve_routing_key(&self, peer_id: &str) -> Result<String, SessionError> {
914 let map = self.peers.read().await;
915 if map.contains_key(peer_id) {
916 return Ok(peer_id.to_string());
917 }
918
919 // Published-ULID lookup goes through `by_device` — the first-wins
920 // authoritative index — so a suppressed duplicate claimant can never
921 // capture ULID-addressed traffic (RFC 022 §7.7). Never match a raw
922 // `identity.device_id` here: suppressed identities are unpublished.
923 {
924 let by_dev = self.by_device.read().await;
925 if let Some(ts) = by_dev.get(peer_id) {
926 return Ok(ts.clone());
927 }
928 }
929
930 if let Some(found) = map.values().find(|p| {
931 p.name == peer_id
932 || (!p.identity_suppressed
933 && p.identity
934 .as_ref()
935 .map(|i| i.device_name == peer_id)
936 .unwrap_or(false))
937 }) {
938 return Ok(found.id.clone());
939 }
940
941 // Peer-ref selector `{tailscale_id}:{generation}` — generation-checked
942 // handle routing (RFC 022 I5). Checked last so identifiers that merely
943 // look ref-shaped can still resolve above; a real ref that reaches
944 // here is live (generation match), superseded, or departed — the
945 // latter two must fail with PeerGone, never silently reach a
946 // rejoined peer.
947 if let Some((ts, generation)) = parse_peer_ref(peer_id) {
948 return match map.get(ts) {
949 Some(p) if p.generation == generation => Ok(ts.to_string()),
950 _ => Err(SessionError::PeerGone(peer_id.to_string())),
951 };
952 }
953
954 Err(SessionError::UnknownPeer(peer_id.to_string()))
955 }
956
957 /// Broadcast data to all peers with active WebSocket connections.
958 ///
959 /// Sends to all currently connected peers. Peers with no active
960 /// connection are skipped (no lazy connect on broadcast).
961 /// Errors from individual sends are logged but do not fail the broadcast.
962 pub async fn broadcast(&self, data: &[u8]) -> BroadcastReport {
963 let conns = self.ws_connections.read().await;
964 let mut report = BroadcastReport {
965 attempted: conns.len(),
966 ..Default::default()
967 };
968
969 for (peer_id, handle) in conns.iter() {
970 if handle.send_tx.send(data.to_vec()).await.is_err() {
971 tracing::warn!(
972 peer_id = %peer_id,
973 "session: broadcast send failed (connection task closed)"
974 );
975 report.failed.push(peer_id.clone());
976 } else {
977 report.queued += 1;
978 }
979 }
980 report
981 }
982
983 /// Subscribe to incoming messages from any connected peer.
984 ///
985 /// Returns a broadcast receiver that yields [`IncomingMessage`]s.
986 /// Messages include the sender's peer ID and raw bytes — Layer 5
987 /// does not interpret the payload.
988 pub fn subscribe(&self) -> broadcast::Receiver<IncomingMessage> {
989 self.incoming_tx.subscribe()
990 }
991
992 /// Test-only: inject an incoming message as if received from a peer,
993 /// so tests can drive the envelope router without a real transport.
994 #[cfg(test)]
995 pub(crate) fn test_inject_incoming(&self, from: &str, data: Vec<u8>) {
996 let _ = self.incoming_tx.send(IncomingMessage {
997 from: from.to_string(),
998 data,
999 received_at: Instant::now(),
1000 });
1001 }
1002
1003 /// Test-only: stamp a synthetic [`PeerIdentity`] onto an existing
1004 /// peer in the registry, simulating the effect of a completed hello
1005 /// exchange without running a real WebSocket handshake. Returns
1006 /// `true` if the peer was found and updated (including suppressed).
1007 #[doc(hidden)]
1008 pub async fn test_stamp_identity(&self, peer_id: &str, identity: PeerIdentity) -> bool {
1009 let mut map = self.peers.write().await;
1010 if !map.contains_key(peer_id) {
1011 return false;
1012 }
1013 let mut by_dev = self.by_device.write().await;
1014 let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
1015 emit_identity_outcomes(&self.event_tx, outcomes);
1016 true
1017 }
1018
1019 /// Look up the published Tailscale id for a durable device ULID, if any.
1020 #[doc(hidden)]
1021 pub async fn test_by_device(&self, device_id: &str) -> Option<String> {
1022 self.by_device.read().await.get(device_id).cloned()
1023 }
1024
1025 /// Disconnect a specific peer's WebSocket connection.
1026 ///
1027 /// Removes the cached connection and marks the peer as disconnected.
1028 /// Does not remove the peer from the registry (that only happens when
1029 /// Layer 3 emits a `Left` event).
1030 pub async fn disconnect(&self, peer_id: &str) {
1031 let handle = {
1032 let mut conns = self.ws_connections.write().await;
1033 conns.remove(peer_id)
1034 };
1035
1036 if let Some(handle) = handle {
1037 // Signal the connection task to close. If the channel is already
1038 // closed (task exited), that's fine.
1039 let _ = handle.close_tx.send(()).await;
1040 }
1041
1042 // Mark peer as disconnected
1043 {
1044 let mut map = self.peers.write().await;
1045 if let Some(state) = map.get_mut(peer_id) {
1046 state.ws_connected = false;
1047 }
1048 }
1049
1050 let _ = self
1051 .event_tx
1052 .send(PeerEvent::WsDisconnected(peer_id.to_string()));
1053 }
1054
1055 /// Close all active WebSocket connections and mark every peer as
1056 /// disconnected. Called by `Node::stop()` during teardown. Safe to call
1057 /// multiple times — with no active connections it is a no-op.
1058 pub async fn shutdown(&self) {
1059 // Stop the background loops first (peer-event + WS accept) so no
1060 // new connections or peer updates arrive mid-teardown. Abort is
1061 // safe: both loops only await channel/accept operations.
1062 for handle in self.background_tasks.lock().unwrap().drain(..) {
1063 handle.abort();
1064 }
1065
1066 let handles: Vec<(String, WsConnectionHandle)> = {
1067 let mut conns = self.ws_connections.write().await;
1068 conns.drain().collect()
1069 };
1070 for (peer_id, handle) in handles {
1071 let _ = handle.close_tx.send(()).await;
1072 let _ = self.event_tx.send(PeerEvent::WsDisconnected(peer_id));
1073 }
1074 let mut map = self.peers.write().await;
1075 for state in map.values_mut() {
1076 state.ws_connected = false;
1077 }
1078 }
1079}
1080
1081// ---------------------------------------------------------------------------
1082// Connection task — exclusively owns the WsFramedStream
1083// ---------------------------------------------------------------------------
1084
1085/// Spawn a background task that exclusively owns a `WsFramedStream`.
1086///
1087/// The task uses `tokio::select!` to multiplex between:
1088/// - Receiving outgoing data from the `send_rx` channel and writing to the WS
1089/// - Reading incoming data from the WS and forwarding to `incoming_tx`
1090/// - Receiving a close signal from `close_rx`
1091///
1092/// When the task exits (stream closed, error, or close signal), it cleans up
1093/// the connection from the registry and emits a `Disconnected` event.
1094///
1095/// Returns a [`WsConnectionHandle`] for the caller to send data and close.
1096fn spawn_connection_task(
1097 stream: WsFramedStream,
1098 peer_id: String,
1099 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1100 peers: Arc<RwLock<HashMap<String, PeerState>>>,
1101 event_tx: broadcast::Sender<PeerEvent>,
1102 incoming_tx: broadcast::Sender<IncomingMessage>,
1103) -> WsConnectionHandle {
1104 let (send_tx, mut send_rx) = mpsc::channel::<Vec<u8>>(256);
1105 let (close_tx, mut close_rx) = mpsc::channel::<()>(1);
1106
1107 let handle = WsConnectionHandle {
1108 send_tx: send_tx.clone(),
1109 close_tx: close_tx.clone(),
1110 peer_id: peer_id.clone(),
1111 connected_at: Instant::now(),
1112 };
1113
1114 // RFC 022 §7.5: attribute inbound traffic by the WhoIs-verified
1115 // Tailscale stable id of this connection — never the self-declared ULID.
1116 let from_tailscale_id = peer_id.clone();
1117
1118 tokio::spawn(async move {
1119 let mut stream = stream;
1120 let mut closed = false;
1121
1122 loop {
1123 tokio::select! {
1124 // Outgoing: data from send channel → write to WS
1125 Some(data) = send_rx.recv() => {
1126 if let Err(e) = stream.send(&data).await {
1127 tracing::warn!(
1128 peer_id = %peer_id,
1129 error = %e,
1130 "session: WS send error"
1131 );
1132 break;
1133 }
1134 }
1135
1136 // Incoming: data from WS → forward to incoming channel
1137 result = stream.recv() => {
1138 match result {
1139 Ok(Some(data)) => {
1140 let msg = IncomingMessage {
1141 from: from_tailscale_id.clone(),
1142 data,
1143 received_at: Instant::now(),
1144 };
1145 let _ = incoming_tx.send(msg);
1146 }
1147 Ok(None) => {
1148 tracing::info!(
1149 peer_id = %peer_id,
1150 "session: WS stream closed"
1151 );
1152 break;
1153 }
1154 Err(e) => {
1155 tracing::warn!(
1156 peer_id = %peer_id,
1157 error = %e,
1158 "session: WS recv error"
1159 );
1160 break;
1161 }
1162 }
1163 }
1164
1165 // Close signal
1166 _ = close_rx.recv() => {
1167 tracing::info!(
1168 peer_id = %peer_id,
1169 "session: connection close requested"
1170 );
1171 closed = true;
1172 let _ = stream.close().await;
1173 break;
1174 }
1175 }
1176 }
1177
1178 // Clean up: remove connection from registry, mark peer as disconnected
1179 // Only clean up if we weren't explicitly closed (disconnect() handles
1180 // its own cleanup to avoid racing).
1181 if !closed {
1182 {
1183 let mut conns = ws_connections.write().await;
1184 conns.remove(&peer_id);
1185 }
1186 {
1187 let mut map = peers.write().await;
1188 if let Some(state) = map.get_mut(&peer_id) {
1189 state.ws_connected = false;
1190 }
1191 }
1192 let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id));
1193 }
1194 });
1195
1196 handle
1197}
1198
1199// ---------------------------------------------------------------------------
1200// Eager identity scheduling (RFC 022 Phase C)
1201// ---------------------------------------------------------------------------
1202
1203/// Deterministic per-peer delay in `0..window_ms` for staggering eager dials
1204/// (RFC 022 §8.1).
1205///
1206/// truffle-core carries no `rand` dependency, so the stagger is a hash of the
1207/// peer id (`DefaultHasher`, fixed-seed SipHash) folded into the window rather
1208/// than a random draw. The properties the eager scheduler relies on:
1209///
1210/// - **Bounded:** always `< window_ms`, and exactly `Duration::ZERO` when
1211/// `window_ms == 0` — which both disables jitter (tests use it to keep eager
1212/// timing deterministic) and avoids a `% 0` panic.
1213/// - **Stable per peer:** one peer id always maps to the same delay for the
1214/// life of the process, so a re-scheduled peer does not thrash.
1215/// - **Spread across peers:** distinct ids land on different offsets, which is
1216/// the whole point — it breaks up the synchronized first-dial burst when a
1217/// node joins a large mesh. Decorrelating the *same* peer across different
1218/// local nodes is out of scope (the herd this targets is one node's own
1219/// outbound burst); the semaphore bounds concurrency regardless.
1220fn eager_jitter_delay(peer_id: &str, window_ms: u64) -> Duration {
1221 if window_ms == 0 {
1222 return Duration::ZERO;
1223 }
1224 use std::collections::hash_map::DefaultHasher;
1225 use std::hash::{Hash, Hasher};
1226 let mut h = DefaultHasher::new();
1227 peer_id.hash(&mut h);
1228 Duration::from_millis(h.finish() % window_ms)
1229}
1230
1231/// Arcs needed to dial for identity without holding `&PeerRegistry`.
1232struct EagerScheduleCtx<N: NetworkProvider + 'static> {
1233 eager_identity: bool,
1234 peers: Arc<RwLock<HashMap<String, PeerState>>>,
1235 by_device: Arc<RwLock<HashMap<String, String>>>,
1236 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1237 peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
1238 connecting: Arc<RwLock<HashSet<String>>>,
1239 event_tx: broadcast::Sender<PeerEvent>,
1240 incoming_tx: broadcast::Sender<IncomingMessage>,
1241 ws_transport: Arc<WebSocketTransport<N>>,
1242 network: Arc<N>,
1243 eager_identity_sem: Arc<Semaphore>,
1244 eager_identity_jitter_ms: u64,
1245 identity_inflight: Arc<AsyncMutex<HashSet<String>>>,
1246}
1247
1248impl<N: NetworkProvider + 'static> EagerScheduleCtx<N> {
1249 fn schedule(&self, peer_id: String) {
1250 if !self.eager_identity {
1251 return;
1252 }
1253
1254 // Dedupe in-flight ensures.
1255 {
1256 // try_lock: if contended, still spawn (double-check inside task).
1257 if let Ok(mut inflight) = self.identity_inflight.try_lock() {
1258 if !inflight.insert(peer_id.clone()) {
1259 return;
1260 }
1261 }
1262 }
1263
1264 let peers = self.peers.clone();
1265 let by_device = self.by_device.clone();
1266 let ws_connections = self.ws_connections.clone();
1267 let peer_backoffs = self.peer_backoffs.clone();
1268 let connecting = self.connecting.clone();
1269 let event_tx = self.event_tx.clone();
1270 let incoming_tx = self.incoming_tx.clone();
1271 let ws_transport = self.ws_transport.clone();
1272 let _network = self.network.clone();
1273 let sem = self.eager_identity_sem.clone();
1274 let jitter_ms = self.eager_identity_jitter_ms;
1275 let inflight = self.identity_inflight.clone();
1276
1277 tokio::spawn(async move {
1278 // Mark inflight (if try_lock missed earlier).
1279 {
1280 let mut set = inflight.lock().await;
1281 set.insert(peer_id.clone());
1282 }
1283
1284 // RFC 022 §8.1: stagger the first burst of eager hellos with a
1285 // bounded per-peer delay applied *before* acquiring the dial
1286 // permit, so a node joining a large mesh does not fire its first
1287 // `eager_identity_concurrency` hellos simultaneously. Waiting
1288 // before the semaphore (not after) means we never hold a dial slot
1289 // just to idle, and it spreads arrival at the semaphore so even the
1290 // very first dials are staggered. The delay is derived from the
1291 // peer id (no `rand` dependency); only this eager path waits — app
1292 // `send` never does.
1293 let delay = eager_jitter_delay(&peer_id, jitter_ms);
1294 if !delay.is_zero() {
1295 tokio::time::sleep(delay).await;
1296 }
1297
1298 let permit = match sem.acquire().await {
1299 Ok(p) => p,
1300 Err(_) => {
1301 let mut set = inflight.lock().await;
1302 set.remove(&peer_id);
1303 return;
1304 }
1305 };
1306
1307 // Skip if identity already known or peer gone/offline.
1308 let needs = {
1309 let map = peers.read().await;
1310 match map.get(&peer_id) {
1311 Some(s) => s.online && s.published_device_id().is_none(),
1312 None => false,
1313 }
1314 };
1315
1316 if needs {
1317 // Build a temporary view with the same connection logic as
1318 // PeerRegistry::ensure_ws_connected (duplicated fields).
1319 if let Err(e) = eager_connect_ws(
1320 &peer_id,
1321 &peers,
1322 &by_device,
1323 &ws_connections,
1324 &peer_backoffs,
1325 &connecting,
1326 &event_tx,
1327 &incoming_tx,
1328 &ws_transport,
1329 )
1330 .await
1331 {
1332 tracing::debug!(
1333 peer_id = %peer_id,
1334 error = %e,
1335 "session: eager identity dial failed"
1336 );
1337 }
1338 }
1339
1340 drop(permit);
1341 let mut set = inflight.lock().await;
1342 set.remove(&peer_id);
1343 });
1344 }
1345}
1346
1347/// Connection path shared by eager identity (no send payload).
1348async fn eager_connect_ws<N: NetworkProvider + 'static>(
1349 peer_id: &str,
1350 peers: &Arc<RwLock<HashMap<String, PeerState>>>,
1351 by_device: &Arc<RwLock<HashMap<String, String>>>,
1352 ws_connections: &Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1353 peer_backoffs: &Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
1354 connecting: &Arc<RwLock<HashSet<String>>>,
1355 event_tx: &broadcast::Sender<PeerEvent>,
1356 incoming_tx: &broadcast::Sender<IncomingMessage>,
1357 ws_transport: &Arc<WebSocketTransport<N>>,
1358) -> Result<(), SessionError> {
1359 {
1360 let conns = ws_connections.read().await;
1361 if conns.contains_key(peer_id) {
1362 return Ok(());
1363 }
1364 }
1365
1366 let peer_addr = {
1367 let map = peers.read().await;
1368 let state = map
1369 .get(peer_id)
1370 .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
1371 if !state.online {
1372 return Err(SessionError::PeerOffline(peer_id.to_string()));
1373 }
1374 if state.published_device_id().is_some() {
1375 return Ok(());
1376 }
1377 PeerAddr {
1378 ip: Some(state.ip),
1379 hostname: state.name.clone(),
1380 dns_name: None,
1381 }
1382 };
1383
1384 {
1385 let backoffs = peer_backoffs.read().await;
1386 if let Some(backoff) = backoffs.get(peer_id) {
1387 if backoff.should_retry().is_none() {
1388 return Err(SessionError::ReconnectBackoff {
1389 retry_after: backoff.retry_after(),
1390 });
1391 }
1392 }
1393 }
1394
1395 {
1396 let mut connecting_g = connecting.write().await;
1397 if connecting_g.contains(peer_id) {
1398 return Err(SessionError::ConnectFailed(
1399 "connection already in progress".to_string(),
1400 ));
1401 }
1402 connecting_g.insert(peer_id.to_string());
1403 }
1404
1405 tracing::info!(peer_id = %peer_id, "session: eager identity connecting WS");
1406
1407 let connect_result = ws_transport.connect(&peer_addr).await;
1408
1409 {
1410 let mut connecting_g = connecting.write().await;
1411 connecting_g.remove(peer_id);
1412 }
1413
1414 let ws_stream = match connect_result {
1415 Ok(stream) => {
1416 let mut backoffs = peer_backoffs.write().await;
1417 backoffs
1418 .entry(peer_id.to_string())
1419 .or_insert_with(ReconnectBackoff::new)
1420 .success();
1421 stream
1422 }
1423 Err(e) => {
1424 let mut backoffs = peer_backoffs.write().await;
1425 backoffs
1426 .entry(peer_id.to_string())
1427 .or_insert_with(ReconnectBackoff::new)
1428 .failure();
1429 return Err(SessionError::ConnectFailed(e.to_string()));
1430 }
1431 };
1432
1433 // RFC 022 §7.5, dial side: same claimed-vs-dialed check as
1434 // `ensure_ws_connected` — an eager hello must never adopt an identity
1435 // from an answerer that is not the peer it dialed.
1436 if let Some(claimed) = ws_stream.remote_identity() {
1437 if claimed.tailscale_id != peer_id {
1438 tracing::warn!(
1439 peer_id = %peer_id,
1440 claimed = %claimed.tailscale_id,
1441 "session: eager dial answered as a different tailscale_id; dropping connection"
1442 );
1443 return Err(SessionError::ConnectFailed(format!(
1444 "hello identity mismatch: dialed {peer_id}, answerer claims {}",
1445 claimed.tailscale_id
1446 )));
1447 }
1448 }
1449
1450 let remote_identity = ws_stream.remote_identity().cloned();
1451
1452 let handle = spawn_connection_task(
1453 ws_stream,
1454 peer_id.to_string(),
1455 ws_connections.clone(),
1456 peers.clone(),
1457 event_tx.clone(),
1458 incoming_tx.clone(),
1459 );
1460
1461 {
1462 let mut conns = ws_connections.write().await;
1463 conns.insert(peer_id.to_string(), handle);
1464 }
1465
1466 {
1467 let mut map = peers.write().await;
1468 let mut by_dev = by_device.write().await;
1469 if let Some(state) = map.get_mut(peer_id) {
1470 state.ws_connected = true;
1471 }
1472 if let Some(identity) = remote_identity {
1473 let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
1474 emit_identity_outcomes(event_tx, outcomes);
1475 }
1476 }
1477
1478 let _ = event_tx.send(PeerEvent::WsConnected(peer_id.to_string()));
1479 Ok(())
1480}
1481
1482// ---------------------------------------------------------------------------
1483// Helper: convert NetworkPeer to PeerState
1484// ---------------------------------------------------------------------------
1485
1486/// Convert a Layer 3 `NetworkPeer` to a Layer 5 `PeerState`.
1487///
1488/// Sets `ws_connected: false` by default — connections are managed by Layer 5,
1489/// not by Layer 3 discovery. `generation` must be supplied by the registry
1490/// (bumped per re-join of the same Tailscale id).
1491fn network_peer_to_state(peer: &NetworkPeer, generation: u64) -> PeerState {
1492 let connection_type = if let Some(ref relay) = peer.relay {
1493 format!("relay:{relay}")
1494 } else if peer.cur_addr.is_some() {
1495 "direct".to_string()
1496 } else {
1497 "unknown".to_string()
1498 };
1499
1500 PeerState {
1501 id: peer.id.clone(),
1502 generation,
1503 name: peer.hostname.clone(),
1504 ip: peer.ip,
1505 online: peer.online,
1506 ws_connected: false,
1507 connection_type,
1508 os: peer.os.clone(),
1509 last_seen: peer.last_seen.clone(),
1510 identity: None,
1511 identity_suppressed: false,
1512 }
1513}
1514
1515// ---------------------------------------------------------------------------
1516// Identity application (RFC 022 §7.7)
1517// ---------------------------------------------------------------------------
1518
1519/// Side effects from applying a hello identity to a registry entry.
1520#[derive(Debug)]
1521enum IdentityOutcome {
1522 /// Emit `PeerEvent::Identity` for this snapshot.
1523 Identity(PeerState),
1524 /// Retire a ghost entry (same ULID, offline holder) before the new claim.
1525 /// Carries the ghost's final state for the synthesized `Left` event.
1526 GhostLeft(PeerState),
1527 /// Emit `PeerEvent::Updated` — identity metadata (name/os) changed on a
1528 /// re-hello without the ULID changing (no second `identity`, RFC 022 §8).
1529 Updated(PeerState),
1530}
1531
1532/// Apply `identity` to the peer at `ts_id`, updating `by_device` under
1533/// first-wins / ghost-retire / rotation rules.
1534fn apply_identity(
1535 peers: &mut HashMap<String, PeerState>,
1536 by_device: &mut HashMap<String, String>,
1537 ts_id: &str,
1538 identity: PeerIdentity,
1539) -> Vec<IdentityOutcome> {
1540 let mut outcomes = Vec::new();
1541 let uid = identity.device_id.clone();
1542
1543 let Some(state) = peers.get(ts_id) else {
1544 return outcomes;
1545 };
1546
1547 // Rotation: same entry, different ULID already published.
1548 if let Some(prev) = state.published_device_id() {
1549 if prev != uid.as_str() {
1550 by_device.remove(prev);
1551 } else if !state.identity_suppressed {
1552 // Same ULID already published — refresh metadata silently:
1553 // RFC 022 §8 says later confirmations emit no second `identity`
1554 // (every WS reconnect re-hellos). A changed name/os surfaces as
1555 // `updated` instead.
1556 if let Some(s) = peers.get_mut(ts_id) {
1557 let changed = s.identity.as_ref() != Some(&identity);
1558 s.identity = Some(identity);
1559 s.identity_suppressed = false;
1560 if changed {
1561 outcomes.push(IdentityOutcome::Updated(s.clone()));
1562 }
1563 }
1564 return outcomes;
1565 }
1566 }
1567
1568 match by_device.get(&uid).cloned() {
1569 Some(holder) if holder == ts_id => {
1570 // Already the published owner — update identity block.
1571 if let Some(s) = peers.get_mut(ts_id) {
1572 s.identity = Some(identity);
1573 s.identity_suppressed = false;
1574 outcomes.push(IdentityOutcome::Identity(s.clone()));
1575 }
1576 }
1577 Some(holder) => {
1578 let holder_online = peers.get(&holder).map(|p| p.online).unwrap_or(false);
1579 if holder_online {
1580 // First-wins: store identity but suppress publication.
1581 tracing::warn!(
1582 device_id = %uid,
1583 holder = %holder,
1584 claimant = %ts_id,
1585 "session: duplicate-device-id — first-wins, suppressing claimant"
1586 );
1587 if let Some(s) = peers.get_mut(ts_id) {
1588 s.identity = Some(identity);
1589 s.identity_suppressed = true;
1590 // No Identity event for suppressed claim (deviceId stays null).
1591 }
1592 } else {
1593 // Ghost retire: offline holder loses the ULID.
1594 if let Some(mut ghost) = peers.remove(&holder) {
1595 ghost.online = false;
1596 ghost.ws_connected = false;
1597 outcomes.push(IdentityOutcome::GhostLeft(ghost));
1598 }
1599 by_device.insert(uid.clone(), ts_id.to_string());
1600 if let Some(s) = peers.get_mut(ts_id) {
1601 s.identity = Some(identity);
1602 s.identity_suppressed = false;
1603 outcomes.push(IdentityOutcome::Identity(s.clone()));
1604 }
1605 }
1606 }
1607 None => {
1608 by_device.insert(uid, ts_id.to_string());
1609 if let Some(s) = peers.get_mut(ts_id) {
1610 s.identity = Some(identity);
1611 s.identity_suppressed = false;
1612 outcomes.push(IdentityOutcome::Identity(s.clone()));
1613 }
1614 }
1615 }
1616
1617 outcomes
1618}
1619
1620fn emit_identity_outcomes(event_tx: &broadcast::Sender<PeerEvent>, outcomes: Vec<IdentityOutcome>) {
1621 for o in outcomes {
1622 match o {
1623 IdentityOutcome::GhostLeft(state) => {
1624 let _ = event_tx.send(PeerEvent::Left(state));
1625 }
1626 IdentityOutcome::Identity(state) => {
1627 let _ = event_tx.send(PeerEvent::Identity(state));
1628 }
1629 IdentityOutcome::Updated(state) => {
1630 let _ = event_tx.send(PeerEvent::Updated(state));
1631 }
1632 }
1633 }
1634}