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 /// Whether this node dialed or accepted the connection.
250 direction: ConnectionDirection,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254enum ConnectionDirection {
255 Inbound,
256 Outbound,
257}
258
259// ---------------------------------------------------------------------------
260// PeerRegistry
261// ---------------------------------------------------------------------------
262
263/// Manages peer state and WebSocket connections.
264///
265/// The `PeerRegistry` is the heart of Layer 5. It:
266///
267/// 1. **Tracks peers** from Layer 3 discovery events — peers exist in the
268/// registry even with zero transport connections.
269/// 2. **Manages lazy connections** — the first [`send()`](Self::send) to a
270/// peer triggers a WebSocket connection via Layer 4. Subsequent sends
271/// reuse the cached connection.
272/// 3. **Routes messages** — incoming messages from any peer are forwarded
273/// to subscribers via a broadcast channel.
274/// 4. **Emits lifecycle events** — [`PeerEvent`]s for peer discovery changes
275/// and connection state changes.
276///
277/// # Example
278///
279/// ```ignore
280/// use std::sync::Arc;
281/// use truffle_core::session::PeerRegistry;
282///
283/// let registry = PeerRegistry::new(network, ws_transport);
284/// registry.start().await;
285///
286/// // Peers appear from Layer 3 discovery
287/// let peers = registry.peers().await;
288///
289/// // First send lazily connects
290/// registry.send("peer-id", b"hello").await?;
291/// ```
292pub struct PeerRegistry<N: NetworkProvider + 'static> {
293 /// Layer 3 network provider (for peer events and addressing).
294 network: Arc<N>,
295 /// Layer 4 WebSocket transport (for framed connections).
296 ws_transport: Arc<WebSocketTransport<N>>,
297
298 /// All known peers from Layer 3, keyed by Tailscale stable id.
299 /// Peers exist here even with zero connections.
300 peers: Arc<RwLock<HashMap<String, PeerState>>>,
301
302 /// Durable ULID → Tailscale id for at most one **published** live entry
303 /// (RFC 022 §7.7). Used for queries only — never for message attribution.
304 by_device: Arc<RwLock<HashMap<String, String>>>,
305
306 /// Next generation number per Tailscale id (incremented on each join).
307 next_generation: Arc<RwLock<HashMap<String, u64>>>,
308
309 /// Active WebSocket connection handles indexed by peer_id (Tailscale id).
310 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
311
312 /// Hello identities accepted before Layer 3 has announced the peer.
313 /// Reconciled into `peers` on the subsequent Joined/Updated event.
314 pending_identities: Arc<RwLock<HashMap<String, PeerIdentity>>>,
315
316 /// Reconnect backoff trackers per peer.
317 peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
318
319 /// Set of peer IDs currently being connected to (prevents duplicate dials).
320 connecting: Arc<RwLock<HashSet<String>>>,
321
322 /// Event channel for peer changes (discovery + connection lifecycle).
323 event_tx: broadcast::Sender<PeerEvent>,
324
325 /// Channel for incoming messages from any connected peer.
326 incoming_tx: broadcast::Sender<IncomingMessage>,
327
328 /// Handles to the registry's background loops (peer-event + WS accept),
329 /// retained so [`Self::shutdown`] can abort them instead of leaving
330 /// them running until every Arc clone drops.
331 background_tasks: Arc<std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>>,
332
333 /// RFC 022 Phase C: proactively exchange hello with online peers.
334 eager_identity: bool,
335
336 /// Cap concurrent eager-identity dials (default 4).
337 eager_identity_sem: Arc<Semaphore>,
338
339 /// Per-peer jitter window (ms) applied before an eager dial (RFC 022 §8.1).
340 eager_identity_jitter_ms: u64,
341
342 /// Peer ids with an in-flight ensure_identity task (dedupe).
343 identity_inflight: Arc<AsyncMutex<HashSet<String>>>,
344}
345
346/// Options for [`PeerRegistry::with_options`].
347#[derive(Debug, Clone)]
348pub struct PeerRegistryOptions {
349 /// When true (default), dial the envelope WS once per online peer to
350 /// learn durable identity without waiting for app `send` (RFC 022 §8).
351 pub eager_identity: bool,
352 /// Max concurrent eager-identity dials. Default: 4.
353 pub eager_identity_concurrency: usize,
354 /// Max per-peer delay (ms) applied *before* each EAGER identity dial to
355 /// stagger the first burst of hellos when a node joins a large mesh
356 /// (RFC 022 §8.1). The delay is a deterministic hash of the peer id in
357 /// `0..eager_identity_jitter_ms` (truffle-core has no `rand` dependency).
358 /// `0` disables jitter — tests set it for deterministic timing. Only the
359 /// eager path is delayed; app `send` / `ensure_ws_connected` never wait.
360 /// Default: 250.
361 pub eager_identity_jitter_ms: u64,
362}
363
364impl Default for PeerRegistryOptions {
365 fn default() -> Self {
366 Self {
367 eager_identity: true,
368 eager_identity_concurrency: 4,
369 eager_identity_jitter_ms: 250,
370 }
371 }
372}
373
374impl<N: NetworkProvider + 'static> PeerRegistry<N> {
375 /// Create a new peer registry with default options (eager identity on).
376 ///
377 /// - `network`: The Layer 3 network provider for peer discovery.
378 /// - `ws_transport`: The Layer 4 WebSocket transport for connections.
379 ///
380 /// Call [`start()`](Self::start) after creation to begin processing
381 /// peer events and accepting incoming connections.
382 pub fn new(network: Arc<N>, ws_transport: Arc<WebSocketTransport<N>>) -> Self {
383 Self::with_options(network, ws_transport, PeerRegistryOptions::default())
384 }
385
386 /// Create a peer registry with explicit options (RFC 022 Phase C).
387 pub fn with_options(
388 network: Arc<N>,
389 ws_transport: Arc<WebSocketTransport<N>>,
390 options: PeerRegistryOptions,
391 ) -> Self {
392 let (event_tx, _) = broadcast::channel(256);
393 let (incoming_tx, _) = broadcast::channel(1024);
394 let concurrency = options.eager_identity_concurrency.max(1);
395
396 Self {
397 network,
398 ws_transport,
399 peers: Arc::new(RwLock::new(HashMap::new())),
400 by_device: Arc::new(RwLock::new(HashMap::new())),
401 next_generation: Arc::new(RwLock::new(HashMap::new())),
402 ws_connections: Arc::new(RwLock::new(HashMap::new())),
403 pending_identities: Arc::new(RwLock::new(HashMap::new())),
404 peer_backoffs: Arc::new(RwLock::new(HashMap::new())),
405 connecting: Arc::new(RwLock::new(HashSet::new())),
406 event_tx,
407 incoming_tx,
408 background_tasks: Arc::new(std::sync::Mutex::new(Vec::new())),
409 eager_identity: options.eager_identity,
410 eager_identity_sem: Arc::new(Semaphore::new(concurrency)),
411 eager_identity_jitter_ms: options.eager_identity_jitter_ms,
412 identity_inflight: Arc::new(AsyncMutex::new(HashSet::new())),
413 }
414 }
415
416 /// Start the peer registry.
417 ///
418 /// This spawns two background tasks:
419 /// 1. A task that subscribes to Layer 3 peer events and maintains the
420 /// peer list (Joined/Left/Updated).
421 /// 2. A task that listens for incoming WebSocket connections from peers
422 /// and spawns connection tasks for each.
423 ///
424 /// Call this once after constructing the registry.
425 pub async fn start(&self) {
426 // Task 1: Subscribe to Layer 3 peer events
427 self.spawn_peer_event_loop();
428
429 // Task 2: Accept incoming WS connections
430 self.spawn_accept_loop().await;
431 }
432
433 /// Spawn a task that subscribes to Layer 3 peer events and updates the
434 /// internal peer list.
435 fn spawn_peer_event_loop(&self) {
436 let mut events = self.network.peer_events();
437 let peers = self.peers.clone();
438 let by_device = self.by_device.clone();
439 let next_generation = self.next_generation.clone();
440 let ws_connections = self.ws_connections.clone();
441 let pending_identities = self.pending_identities.clone();
442 let event_tx = self.event_tx.clone();
443 // Clones for scheduling eager identity from the event loop.
444 let schedule_ctx = EagerScheduleCtx {
445 eager_identity: self.eager_identity,
446 peers: self.peers.clone(),
447 by_device: self.by_device.clone(),
448 ws_connections: self.ws_connections.clone(),
449 peer_backoffs: self.peer_backoffs.clone(),
450 connecting: self.connecting.clone(),
451 event_tx: self.event_tx.clone(),
452 incoming_tx: self.incoming_tx.clone(),
453 ws_transport: self.ws_transport.clone(),
454 network: self.network.clone(),
455 eager_identity_sem: self.eager_identity_sem.clone(),
456 eager_identity_jitter_ms: self.eager_identity_jitter_ms,
457 identity_inflight: self.identity_inflight.clone(),
458 background_tasks: self.background_tasks.clone(),
459 };
460
461 let handle = tokio::spawn(async move {
462 loop {
463 match events.recv().await {
464 Ok(NetworkPeerEvent::Joined(network_peer)) => {
465 let generation = {
466 let mut gens = next_generation.write().await;
467 let e = gens.entry(network_peer.id.clone()).or_insert(0);
468 *e += 1;
469 *e
470 };
471 let mut state = network_peer_to_state(&network_peer, generation);
472 state.ws_connected =
473 ws_connections.read().await.contains_key(&network_peer.id);
474 let peer_id = network_peer.id.clone();
475 let peer_event = PeerEvent::Joined(state.clone());
476 let pending_identity = {
477 let mut pending = pending_identities.write().await;
478 pending.remove(&peer_id)
479 };
480
481 let identity_outcomes = {
482 let mut map = peers.write().await;
483 map.insert(network_peer.id.clone(), state);
484 if let Some(identity) = pending_identity {
485 let mut by_dev = by_device.write().await;
486 apply_identity(&mut map, &mut by_dev, &peer_id, identity)
487 } else {
488 Vec::new()
489 }
490 };
491
492 let _ = event_tx.send(peer_event);
493 emit_identity_outcomes(&event_tx, identity_outcomes);
494 tracing::info!(
495 peer_id = %network_peer.id,
496 generation,
497 peer_name = %network_peer.hostname,
498 "session: peer joined"
499 );
500
501 let needs_identity = {
502 let map = peers.read().await;
503 map.get(&peer_id)
504 .map(|state| state.online && state.published_device_id().is_none())
505 .unwrap_or(false)
506 };
507 if needs_identity {
508 schedule_ctx.schedule(peer_id);
509 }
510 }
511 Ok(NetworkPeerEvent::Left(peer_id)) => {
512 pending_identities.write().await.remove(&peer_id);
513 // Close any active WS connection for this peer
514 let handle = {
515 let mut conns = ws_connections.write().await;
516 conns.remove(&peer_id)
517 };
518 if let Some(handle) = handle {
519 let _ = handle.close_tx.send(()).await;
520 // Emit Disconnected before Left
521 let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id.clone()));
522 tracing::info!(
523 peer_id = %peer_id,
524 "session: closed WS connection for departing peer"
525 );
526 }
527
528 let removed = {
529 let mut map = peers.write().await;
530 map.remove(&peer_id)
531 };
532
533 // Drop by_device mapping if it pointed at this peer; promote
534 // a suppressed claimant if one exists (RFC 022 §7.7).
535 if let Some(mut removed) = removed {
536 let promote = {
537 let mut by_dev = by_device.write().await;
538 if let Some(uid) = removed.published_device_id() {
539 if by_dev.get(uid).map(|s| s.as_str()) == Some(peer_id.as_str())
540 {
541 by_dev.remove(uid);
542 Some(uid.to_string())
543 } else {
544 None
545 }
546 } else if let Some(ref ident) = removed.identity {
547 // Suppressed holder leaving — nothing published
548 let _ = ident;
549 None
550 } else {
551 None
552 }
553 };
554
555 if let Some(uid) = promote {
556 let mut map = peers.write().await;
557 let mut by_dev = by_device.write().await;
558 if let Some(promoted) = map.values_mut().find(|p| {
559 p.id != peer_id
560 && p.online
561 && p.identity
562 .as_ref()
563 .map(|i| i.device_id == uid)
564 .unwrap_or(false)
565 && p.identity_suppressed
566 }) {
567 promoted.identity_suppressed = false;
568 by_dev.insert(uid.clone(), promoted.id.clone());
569 let snap = promoted.clone();
570 drop(map);
571 drop(by_dev);
572 let _ = event_tx.send(PeerEvent::Identity(snap));
573 tracing::info!(
574 device_id = %uid,
575 "session: promoted suppressed ULID claimant after holder left"
576 );
577 }
578 }
579
580 // Emit `Left` with the entry's final view: the map
581 // entry is already gone, and consumers need a
582 // usable last state for cleanup (RFC 022 §16.4).
583 removed.online = false;
584 removed.ws_connected = false;
585 let _ = event_tx.send(PeerEvent::Left(removed));
586 tracing::info!(peer_id = %peer_id, "session: peer left");
587 } else {
588 // Never announced via `joined` — nothing to retire,
589 // so no `left` is emitted either.
590 tracing::debug!(
591 peer_id = %peer_id,
592 "session: left for unknown peer; no event"
593 );
594 }
595 }
596 Ok(NetworkPeerEvent::Updated(network_peer)) => {
597 let mut state = network_peer_to_state(&network_peer, 0);
598 // Both branches below assign this before it is read;
599 // no initializer keeps the unused-assignment lint quiet.
600 let mut became_online_without_identity;
601
602 // Preserve Layer 5 state (ws_connected, identity,
603 // generation, suppression) from the existing entry —
604 // Layer 3 Updated events only carry discovery metadata.
605 {
606 let pending_identity = {
607 let mut pending = pending_identities.write().await;
608 pending.remove(&network_peer.id)
609 };
610 let mut map = peers.write().await;
611 if let Some(existing) = map.get(&network_peer.id) {
612 state.generation = existing.generation;
613 state.ws_connected = existing.ws_connected;
614 state.identity = existing.identity.clone();
615 state.identity_suppressed = existing.identity_suppressed;
616 became_online_without_identity = !existing.online
617 && state.online
618 && existing.published_device_id().is_none();
619 } else {
620 // Unknown peer Updated without Joined — treat as gen 1.
621 let mut gens = next_generation.write().await;
622 let e = gens.entry(network_peer.id.clone()).or_insert(0);
623 *e += 1;
624 state.generation = *e;
625 became_online_without_identity =
626 state.online && state.published_device_id().is_none();
627 }
628 state.ws_connected =
629 ws_connections.read().await.contains_key(&network_peer.id);
630 map.insert(network_peer.id.clone(), state.clone());
631 if let Some(identity) = pending_identity {
632 let mut by_dev = by_device.write().await;
633 let outcomes = apply_identity(
634 &mut map,
635 &mut by_dev,
636 &network_peer.id,
637 identity,
638 );
639 emit_identity_outcomes(&event_tx, outcomes);
640 became_online_without_identity = map
641 .get(&network_peer.id)
642 .map(|state| {
643 state.online && state.published_device_id().is_none()
644 })
645 .unwrap_or(false);
646 state = map
647 .get(&network_peer.id)
648 .cloned()
649 .expect("peer inserted immediately above");
650 }
651 }
652
653 let peer_id = network_peer.id.clone();
654 let _ = event_tx.send(PeerEvent::Updated(state));
655 tracing::debug!(
656 peer_id = %network_peer.id,
657 "session: peer updated"
658 );
659
660 if became_online_without_identity {
661 schedule_ctx.schedule(peer_id);
662 }
663 }
664 Ok(NetworkPeerEvent::AuthRequired { url }) => {
665 let _ = event_tx.send(PeerEvent::AuthRequired { url });
666 }
667 Err(broadcast::error::RecvError::Lagged(n)) => {
668 tracing::warn!(
669 missed = n,
670 "session: peer event receiver lagged, missed {n} events"
671 );
672 }
673 Err(broadcast::error::RecvError::Closed) => {
674 tracing::debug!("session: peer event channel closed");
675 break;
676 }
677 }
678 }
679 });
680 self.background_tasks.lock().unwrap().push(handle);
681 }
682
683 /// Spawn a task that accepts incoming WebSocket connections from peers.
684 async fn spawn_accept_loop(&self) {
685 let ws_transport = self.ws_transport.clone();
686 let ws_connections = self.ws_connections.clone();
687 let peers = self.peers.clone();
688 let by_device = self.by_device.clone();
689 let pending_identities = self.pending_identities.clone();
690 let event_tx = self.event_tx.clone();
691 let incoming_tx = self.incoming_tx.clone();
692 let local_tailscale_id = self.network.local_identity().tailscale_id;
693
694 // Try to start the WS listener. If it fails, log and return.
695 let mut listener = match ws_transport.listen().await {
696 Ok(l) => l,
697 Err(e) => {
698 tracing::error!("session: failed to start WS listener: {e}");
699 return;
700 }
701 };
702
703 let handle = tokio::spawn(async move {
704 loop {
705 match listener.accept().await {
706 Some(stream) => {
707 let peer_id = stream.remote_peer_id().to_string();
708 let remote_identity = stream.remote_identity().cloned();
709 tracing::info!(
710 peer_id = %peer_id,
711 device_id = remote_identity.as_ref().map(|i| i.device_id.as_str()),
712 "session: accepted incoming WS connection"
713 );
714
715 // Create connection handle and spawn connection task.
716 // Attribution uses WhoIs-verified Tailscale id (peer_id).
717 let handle = spawn_connection_task(
718 stream,
719 peer_id.clone(),
720 ConnectionDirection::Inbound,
721 ws_connections.clone(),
722 peers.clone(),
723 event_tx.clone(),
724 incoming_tx.clone(),
725 );
726
727 if !install_connection(
728 &ws_connections,
729 &local_tailscale_id,
730 &peer_id,
731 handle,
732 )
733 .await
734 {
735 tracing::debug!(
736 peer_id = %peer_id,
737 "session: rejected duplicate incoming WS connection"
738 );
739 continue;
740 }
741
742 // Mark peer as connected and apply identity from hello.
743 // An inbound hello can win the race with Layer 3
744 // discovery; retain it until Joined/Updated creates the
745 // peer entry instead of silently discarding identity.
746 {
747 let mut pending = pending_identities.write().await;
748 let mut map = peers.write().await;
749 let mut by_dev = by_device.write().await;
750 if let Some(state) = map.get_mut(&peer_id) {
751 state.ws_connected = true;
752 if let Some(identity) = remote_identity {
753 let outcomes =
754 apply_identity(&mut map, &mut by_dev, &peer_id, identity);
755 emit_identity_outcomes(&event_tx, outcomes);
756 }
757 } else if let Some(identity) = remote_identity {
758 pending.insert(peer_id.clone(), identity);
759 }
760 }
761
762 let _ = event_tx.send(PeerEvent::WsConnected(peer_id));
763 }
764 None => {
765 tracing::debug!("session: WS listener closed");
766 break;
767 }
768 }
769 }
770 });
771 self.background_tasks.lock().unwrap().push(handle);
772 }
773
774 /// Return all known peers.
775 ///
776 /// This returns peers discovered by Layer 3, including those with
777 /// no active transport connections (`ws_connected: false`).
778 pub async fn peers(&self) -> Vec<PeerState> {
779 let map = self.peers.read().await;
780 map.values().cloned().collect()
781 }
782
783 /// Subscribe to peer change events.
784 ///
785 /// Returns a broadcast receiver that yields [`PeerEvent`]s for peer
786 /// discovery changes (Joined/Left/Updated) and connection lifecycle
787 /// changes (Connected/Disconnected).
788 pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
789 self.event_tx.subscribe()
790 }
791
792 /// Send data to a specific peer.
793 ///
794 /// If no WebSocket connection exists to the peer, one is lazily
795 /// established via Layer 4. The connection is cached for subsequent
796 /// sends. If the peer is unknown or offline, an error is returned.
797 ///
798 /// # Errors
799 ///
800 /// - [`SessionError::UnknownPeer`] if the peer is not in the registry
801 /// - [`SessionError::PeerOffline`] if Layer 3 reports the peer as offline
802 /// - [`SessionError::ConnectFailed`] if the WS connection cannot be established
803 /// - [`SessionError::SendFailed`] if the send operation fails
804 pub async fn send(&self, peer_id: &str, data: &[u8]) -> Result<(), SessionError> {
805 let peer_id = self.resolve_routing_key(peer_id).await?;
806 self.ensure_ws_connected(&peer_id).await?;
807
808 let conns = self.ws_connections.read().await;
809 let handle = conns
810 .get(&peer_id)
811 .ok_or_else(|| SessionError::SendFailed("connection missing after connect".into()))?;
812 handle
813 .send_tx
814 .send(data.to_vec())
815 .await
816 .map_err(|_| SessionError::SendFailed("connection task closed".to_string()))
817 }
818
819 /// Ensure a WS session exists to `peer_id` (Tailscale routing key).
820 /// Completes the RFC 017 hello and applies identity (RFC 022).
821 ///
822 /// Used by app `send` and by eager-identity (Phase C). Idempotent when
823 /// already connected.
824 pub async fn ensure_ws_connected(&self, peer_id: &str) -> Result<(), SessionError> {
825 // Already connected?
826 {
827 let conns = self.ws_connections.read().await;
828 if conns.contains_key(peer_id) {
829 return Ok(());
830 }
831 }
832
833 let peer_addr = {
834 let map = self.peers.read().await;
835 let state = map
836 .get(peer_id)
837 .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
838 if !state.online {
839 return Err(SessionError::PeerOffline(peer_id.to_string()));
840 }
841 PeerAddr {
842 ip: Some(state.ip),
843 hostname: state.name.clone(),
844 dns_name: None,
845 }
846 };
847
848 // Backoff
849 {
850 let backoffs = self.peer_backoffs.read().await;
851 if let Some(backoff) = backoffs.get(peer_id) {
852 if backoff.should_retry().is_none() {
853 let retry_after = backoff.retry_after();
854 return Err(SessionError::ReconnectBackoff { retry_after });
855 }
856 }
857 }
858
859 // Dedupe concurrent dials
860 {
861 let already = {
862 let connecting = self.connecting.read().await;
863 connecting.contains(peer_id)
864 };
865 if already {
866 // Wait briefly for the other dial to finish, then re-check.
867 for _ in 0..50 {
868 tokio::time::sleep(Duration::from_millis(20)).await;
869 let conns = self.ws_connections.read().await;
870 if conns.contains_key(peer_id) {
871 return Ok(());
872 }
873 let connecting = self.connecting.read().await;
874 if !connecting.contains(peer_id) {
875 break;
876 }
877 }
878 let conns = self.ws_connections.read().await;
879 if conns.contains_key(peer_id) {
880 return Ok(());
881 }
882 }
883 let mut connecting = self.connecting.write().await;
884 if connecting.contains(peer_id) {
885 return Err(SessionError::ConnectFailed(
886 "connection already in progress".to_string(),
887 ));
888 }
889 connecting.insert(peer_id.to_string());
890 }
891
892 tracing::info!(peer_id = %peer_id, "session: connecting WS");
893
894 let connect_result = self.ws_transport.connect(&peer_addr).await;
895
896 {
897 let mut connecting = self.connecting.write().await;
898 connecting.remove(peer_id);
899 }
900
901 let ws_stream = match connect_result {
902 Ok(stream) => {
903 let mut backoffs = self.peer_backoffs.write().await;
904 backoffs
905 .entry(peer_id.to_string())
906 .or_insert_with(ReconnectBackoff::new)
907 .success();
908 stream
909 }
910 Err(e) => {
911 let mut backoffs = self.peer_backoffs.write().await;
912 backoffs
913 .entry(peer_id.to_string())
914 .or_insert_with(ReconnectBackoff::new)
915 .failure();
916 return Err(SessionError::ConnectFailed(e.to_string()));
917 }
918 };
919
920 // RFC 022 §7.5, dial side: the answerer's claimed tailscale_id must
921 // match the peer this connection was dialed for. A mismatch means we
922 // reached something other than `peer_id` (port collision, loopback
923 // test rig, or a lying hello) — registering it would poison the
924 // entry's identity and route this peer's traffic to the answerer.
925 if let Some(claimed) = ws_stream.remote_identity() {
926 if claimed.tailscale_id != peer_id {
927 tracing::warn!(
928 peer_id = %peer_id,
929 claimed = %claimed.tailscale_id,
930 "session: dialed peer answered as a different tailscale_id; dropping connection"
931 );
932 return Err(SessionError::ConnectFailed(format!(
933 "hello identity mismatch: dialed {peer_id}, answerer claims {}",
934 claimed.tailscale_id
935 )));
936 }
937 }
938
939 let remote_identity = ws_stream.remote_identity().cloned();
940
941 let handle = spawn_connection_task(
942 ws_stream,
943 peer_id.to_string(),
944 ConnectionDirection::Outbound,
945 self.ws_connections.clone(),
946 self.peers.clone(),
947 self.event_tx.clone(),
948 self.incoming_tx.clone(),
949 );
950
951 if !install_connection(
952 &self.ws_connections,
953 &self.network.local_identity().tailscale_id,
954 peer_id,
955 handle,
956 )
957 .await
958 {
959 // A simultaneous inbound connection won the deterministic
960 // tie-break. Its hello already established the same peer session.
961 return Ok(());
962 }
963
964 {
965 let mut map = self.peers.write().await;
966 let mut by_dev = self.by_device.write().await;
967 if let Some(state) = map.get_mut(peer_id) {
968 state.ws_connected = true;
969 }
970 if let Some(identity) = remote_identity {
971 let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
972 emit_identity_outcomes(&self.event_tx, outcomes);
973 }
974 }
975
976 let _ = self
977 .event_tx
978 .send(PeerEvent::WsConnected(peer_id.to_string()));
979
980 Ok(())
981 }
982
983 /// One-shot identity exchange for an online peer (RFC 022 Phase C).
984 ///
985 /// No-ops if identity is already published or the peer is offline.
986 pub async fn ensure_identity(&self, peer_id: &str) -> Result<(), SessionError> {
987 {
988 let map = self.peers.read().await;
989 match map.get(peer_id) {
990 Some(s) if s.published_device_id().is_some() => return Ok(()),
991 Some(s) if !s.online => {
992 return Err(SessionError::PeerOffline(peer_id.to_string()));
993 }
994 None => return Err(SessionError::UnknownPeer(peer_id.to_string())),
995 _ => {}
996 }
997 }
998 self.ensure_ws_connected(peer_id).await
999 }
1000
1001 async fn resolve_routing_key(&self, peer_id: &str) -> Result<String, SessionError> {
1002 let map = self.peers.read().await;
1003 if map.contains_key(peer_id) {
1004 return Ok(peer_id.to_string());
1005 }
1006
1007 // Published-ULID lookup goes through `by_device` — the first-wins
1008 // authoritative index — so a suppressed duplicate claimant can never
1009 // capture ULID-addressed traffic (RFC 022 §7.7). Never match a raw
1010 // `identity.device_id` here: suppressed identities are unpublished.
1011 {
1012 let by_dev = self.by_device.read().await;
1013 if let Some(ts) = by_dev.get(peer_id) {
1014 return Ok(ts.clone());
1015 }
1016 }
1017
1018 if let Some(found) = map.values().find(|p| {
1019 p.name == peer_id
1020 || (!p.identity_suppressed
1021 && p.identity
1022 .as_ref()
1023 .map(|i| i.device_name == peer_id)
1024 .unwrap_or(false))
1025 }) {
1026 return Ok(found.id.clone());
1027 }
1028
1029 // Peer-ref selector `{tailscale_id}:{generation}` — generation-checked
1030 // handle routing (RFC 022 I5). Checked last so identifiers that merely
1031 // look ref-shaped can still resolve above; a real ref that reaches
1032 // here is live (generation match), superseded, or departed — the
1033 // latter two must fail with PeerGone, never silently reach a
1034 // rejoined peer.
1035 if let Some((ts, generation)) = parse_peer_ref(peer_id) {
1036 return match map.get(ts) {
1037 Some(p) if p.generation == generation => Ok(ts.to_string()),
1038 _ => Err(SessionError::PeerGone(peer_id.to_string())),
1039 };
1040 }
1041
1042 Err(SessionError::UnknownPeer(peer_id.to_string()))
1043 }
1044
1045 /// Broadcast data to all peers with active WebSocket connections.
1046 ///
1047 /// Sends to all currently connected peers. Peers with no active
1048 /// connection are skipped (no lazy connect on broadcast).
1049 /// Errors from individual sends are logged but do not fail the broadcast.
1050 pub async fn broadcast(&self, data: &[u8]) -> BroadcastReport {
1051 let conns = self.ws_connections.read().await;
1052 let mut report = BroadcastReport {
1053 attempted: conns.len(),
1054 ..Default::default()
1055 };
1056
1057 for (peer_id, handle) in conns.iter() {
1058 if handle.send_tx.send(data.to_vec()).await.is_err() {
1059 tracing::warn!(
1060 peer_id = %peer_id,
1061 "session: broadcast send failed (connection task closed)"
1062 );
1063 report.failed.push(peer_id.clone());
1064 } else {
1065 report.queued += 1;
1066 }
1067 }
1068 report
1069 }
1070
1071 /// Subscribe to incoming messages from any connected peer.
1072 ///
1073 /// Returns a broadcast receiver that yields [`IncomingMessage`]s.
1074 /// Messages include the sender's peer ID and raw bytes — Layer 5
1075 /// does not interpret the payload.
1076 pub fn subscribe(&self) -> broadcast::Receiver<IncomingMessage> {
1077 self.incoming_tx.subscribe()
1078 }
1079
1080 /// Test-only: inject an incoming message as if received from a peer,
1081 /// so tests can drive the envelope router without a real transport.
1082 #[cfg(test)]
1083 pub(crate) fn test_inject_incoming(&self, from: &str, data: Vec<u8>) {
1084 let _ = self.incoming_tx.send(IncomingMessage {
1085 from: from.to_string(),
1086 data,
1087 received_at: Instant::now(),
1088 });
1089 }
1090
1091 /// Test-only: stamp a synthetic [`PeerIdentity`] onto an existing
1092 /// peer in the registry, simulating the effect of a completed hello
1093 /// exchange without running a real WebSocket handshake. Returns
1094 /// `true` if the peer was found and updated (including suppressed).
1095 #[doc(hidden)]
1096 pub async fn test_stamp_identity(&self, peer_id: &str, identity: PeerIdentity) -> bool {
1097 let mut map = self.peers.write().await;
1098 if !map.contains_key(peer_id) {
1099 return false;
1100 }
1101 let mut by_dev = self.by_device.write().await;
1102 let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
1103 emit_identity_outcomes(&self.event_tx, outcomes);
1104 true
1105 }
1106
1107 /// Look up the published Tailscale id for a durable device ULID, if any.
1108 #[doc(hidden)]
1109 pub async fn test_by_device(&self, device_id: &str) -> Option<String> {
1110 self.by_device.read().await.get(device_id).cloned()
1111 }
1112
1113 /// Disconnect a specific peer's WebSocket connection.
1114 ///
1115 /// Removes the cached connection and marks the peer as disconnected.
1116 /// Does not remove the peer from the registry (that only happens when
1117 /// Layer 3 emits a `Left` event).
1118 pub async fn disconnect(&self, peer_id: &str) {
1119 let handle = {
1120 let mut conns = self.ws_connections.write().await;
1121 conns.remove(peer_id)
1122 };
1123
1124 if let Some(handle) = handle {
1125 // Signal the connection task to close. If the channel is already
1126 // closed (task exited), that's fine.
1127 let _ = handle.close_tx.send(()).await;
1128 }
1129
1130 // Mark peer as disconnected
1131 {
1132 let mut map = self.peers.write().await;
1133 if let Some(state) = map.get_mut(peer_id) {
1134 state.ws_connected = false;
1135 }
1136 }
1137
1138 let _ = self
1139 .event_tx
1140 .send(PeerEvent::WsDisconnected(peer_id.to_string()));
1141 }
1142
1143 /// Close all active WebSocket connections and mark every peer as
1144 /// disconnected. Called by `Node::stop()` during teardown. Safe to call
1145 /// multiple times — with no active connections it is a no-op.
1146 pub async fn shutdown(&self) {
1147 // Stop the background loops first (peer-event + WS accept) so no
1148 // new connections or peer updates arrive mid-teardown. Abort is
1149 // safe: both loops only await channel/accept operations.
1150 for handle in self.background_tasks.lock().unwrap().drain(..) {
1151 handle.abort();
1152 }
1153
1154 let handles: Vec<(String, WsConnectionHandle)> = {
1155 let mut conns = self.ws_connections.write().await;
1156 conns.drain().collect()
1157 };
1158 for (peer_id, handle) in handles {
1159 let _ = handle.close_tx.send(()).await;
1160 let _ = self.event_tx.send(PeerEvent::WsDisconnected(peer_id));
1161 }
1162 let mut map = self.peers.write().await;
1163 for state in map.values_mut() {
1164 state.ws_connected = false;
1165 }
1166 }
1167}
1168
1169// ---------------------------------------------------------------------------
1170// Connection task — exclusively owns the WsFramedStream
1171// ---------------------------------------------------------------------------
1172
1173/// Install one connection per peer. During simultaneous cross-dials, both
1174/// nodes deterministically keep the same physical connection: the
1175/// lexicographically smaller Tailscale id keeps its outbound side and the
1176/// larger id keeps the matching inbound side. A one-sided/non-preferred
1177/// connection is still accepted when no connection exists.
1178async fn install_connection(
1179 ws_connections: &Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1180 local_tailscale_id: &str,
1181 peer_id: &str,
1182 handle: WsConnectionHandle,
1183) -> bool {
1184 let preferred = preferred_connection_direction(local_tailscale_id, peer_id);
1185
1186 let (installed, to_close) = {
1187 let mut conns = ws_connections.write().await;
1188 let replace = match conns.get(peer_id) {
1189 None => true,
1190 Some(existing) => {
1191 should_replace_connection(existing.direction, handle.direction, preferred)
1192 }
1193 };
1194 if replace {
1195 (true, conns.insert(peer_id.to_string(), handle))
1196 } else {
1197 (false, Some(handle))
1198 }
1199 };
1200
1201 if let Some(old) = to_close {
1202 let _ = old.close_tx.try_send(());
1203 }
1204 installed
1205}
1206
1207fn preferred_connection_direction(
1208 local_tailscale_id: &str,
1209 peer_id: &str,
1210) -> Option<ConnectionDirection> {
1211 if local_tailscale_id.is_empty() || local_tailscale_id == peer_id {
1212 None
1213 } else if local_tailscale_id < peer_id {
1214 Some(ConnectionDirection::Outbound)
1215 } else {
1216 Some(ConnectionDirection::Inbound)
1217 }
1218}
1219
1220fn should_replace_connection(
1221 existing: ConnectionDirection,
1222 candidate: ConnectionDirection,
1223 preferred: Option<ConnectionDirection>,
1224) -> bool {
1225 preferred == Some(candidate) && preferred != Some(existing)
1226}
1227
1228/// Spawn a background task that exclusively owns a `WsFramedStream`.
1229///
1230/// The task uses `tokio::select!` to multiplex between:
1231/// - Receiving outgoing data from the `send_rx` channel and writing to the WS
1232/// - Reading incoming data from the WS and forwarding to `incoming_tx`
1233/// - Receiving a close signal from `close_rx`
1234///
1235/// When the task exits (stream closed, error, or close signal), it cleans up
1236/// the connection from the registry and emits a `Disconnected` event.
1237///
1238/// Returns a [`WsConnectionHandle`] for the caller to send data and close.
1239fn spawn_connection_task(
1240 stream: WsFramedStream,
1241 peer_id: String,
1242 direction: ConnectionDirection,
1243 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1244 peers: Arc<RwLock<HashMap<String, PeerState>>>,
1245 event_tx: broadcast::Sender<PeerEvent>,
1246 incoming_tx: broadcast::Sender<IncomingMessage>,
1247) -> WsConnectionHandle {
1248 let (send_tx, mut send_rx) = mpsc::channel::<Vec<u8>>(256);
1249 let (close_tx, mut close_rx) = mpsc::channel::<()>(1);
1250
1251 let handle = WsConnectionHandle {
1252 send_tx: send_tx.clone(),
1253 close_tx: close_tx.clone(),
1254 peer_id: peer_id.clone(),
1255 connected_at: Instant::now(),
1256 direction,
1257 };
1258 let task_send_tx = send_tx.clone();
1259
1260 // RFC 022 §7.5: attribute inbound traffic by the WhoIs-verified
1261 // Tailscale stable id of this connection — never the self-declared ULID.
1262 let from_tailscale_id = peer_id.clone();
1263
1264 tokio::spawn(async move {
1265 let mut stream = stream;
1266 let mut closed = false;
1267
1268 loop {
1269 tokio::select! {
1270 // Outgoing: data from send channel → write to WS
1271 Some(data) = send_rx.recv() => {
1272 if let Err(e) = stream.send(&data).await {
1273 tracing::warn!(
1274 peer_id = %peer_id,
1275 error = %e,
1276 "session: WS send error"
1277 );
1278 break;
1279 }
1280 }
1281
1282 // Incoming: data from WS → forward to incoming channel
1283 result = stream.recv() => {
1284 match result {
1285 Ok(Some(data)) => {
1286 let msg = IncomingMessage {
1287 from: from_tailscale_id.clone(),
1288 data,
1289 received_at: Instant::now(),
1290 };
1291 let _ = incoming_tx.send(msg);
1292 }
1293 Ok(None) => {
1294 tracing::info!(
1295 peer_id = %peer_id,
1296 "session: WS stream closed"
1297 );
1298 break;
1299 }
1300 Err(e) => {
1301 tracing::warn!(
1302 peer_id = %peer_id,
1303 error = %e,
1304 "session: WS recv error"
1305 );
1306 break;
1307 }
1308 }
1309 }
1310
1311 // Close signal
1312 _ = close_rx.recv() => {
1313 tracing::info!(
1314 peer_id = %peer_id,
1315 "session: connection close requested"
1316 );
1317 closed = true;
1318 let _ = stream.close().await;
1319 break;
1320 }
1321 }
1322 }
1323
1324 // Clean up: remove connection from registry, mark peer as disconnected
1325 // Only clean up if we weren't explicitly closed (disconnect() handles
1326 // its own cleanup to avoid racing).
1327 if !closed {
1328 let owned_slot = {
1329 let mut conns = ws_connections.write().await;
1330 let owns_slot = conns
1331 .get(&peer_id)
1332 .map(|handle| handle.send_tx.same_channel(&task_send_tx))
1333 .unwrap_or(false);
1334 if owns_slot {
1335 conns.remove(&peer_id);
1336 }
1337 owns_slot
1338 };
1339 if owned_slot {
1340 let mut map = peers.write().await;
1341 if let Some(state) = map.get_mut(&peer_id) {
1342 state.ws_connected = false;
1343 }
1344 let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id));
1345 }
1346 }
1347 });
1348
1349 handle
1350}
1351
1352// ---------------------------------------------------------------------------
1353// Eager identity scheduling (RFC 022 Phase C)
1354// ---------------------------------------------------------------------------
1355
1356/// Deterministic per-peer delay in `0..window_ms` for staggering eager dials
1357/// (RFC 022 §8.1).
1358///
1359/// truffle-core carries no `rand` dependency, so the stagger is a hash of the
1360/// peer id (`DefaultHasher`, fixed-seed SipHash) folded into the window rather
1361/// than a random draw. The properties the eager scheduler relies on:
1362///
1363/// - **Bounded:** always `< window_ms`, and exactly `Duration::ZERO` when
1364/// `window_ms == 0` — which both disables jitter (tests use it to keep eager
1365/// timing deterministic) and avoids a `% 0` panic.
1366/// - **Stable per peer:** one peer id always maps to the same delay for the
1367/// life of the process, so a re-scheduled peer does not thrash.
1368/// - **Spread across peers:** distinct ids land on different offsets, which is
1369/// the whole point — it breaks up the synchronized first-dial burst when a
1370/// node joins a large mesh. Decorrelating the *same* peer across different
1371/// local nodes is out of scope (the herd this targets is one node's own
1372/// outbound burst); the semaphore bounds concurrency regardless.
1373fn eager_jitter_delay(peer_id: &str, window_ms: u64) -> Duration {
1374 if window_ms == 0 {
1375 return Duration::ZERO;
1376 }
1377 use std::collections::hash_map::DefaultHasher;
1378 use std::hash::{Hash, Hasher};
1379 let mut h = DefaultHasher::new();
1380 peer_id.hash(&mut h);
1381 Duration::from_millis(h.finish() % window_ms)
1382}
1383
1384/// Arcs needed to dial for identity without holding `&PeerRegistry`.
1385struct EagerScheduleCtx<N: NetworkProvider + 'static> {
1386 eager_identity: bool,
1387 peers: Arc<RwLock<HashMap<String, PeerState>>>,
1388 by_device: Arc<RwLock<HashMap<String, String>>>,
1389 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1390 peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
1391 connecting: Arc<RwLock<HashSet<String>>>,
1392 event_tx: broadcast::Sender<PeerEvent>,
1393 incoming_tx: broadcast::Sender<IncomingMessage>,
1394 ws_transport: Arc<WebSocketTransport<N>>,
1395 network: Arc<N>,
1396 eager_identity_sem: Arc<Semaphore>,
1397 eager_identity_jitter_ms: u64,
1398 identity_inflight: Arc<AsyncMutex<HashSet<String>>>,
1399 background_tasks: Arc<std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>>,
1400}
1401
1402impl<N: NetworkProvider + 'static> EagerScheduleCtx<N> {
1403 fn schedule(&self, peer_id: String) {
1404 if !self.eager_identity {
1405 return;
1406 }
1407
1408 let peers = self.peers.clone();
1409 let by_device = self.by_device.clone();
1410 let ws_connections = self.ws_connections.clone();
1411 let peer_backoffs = self.peer_backoffs.clone();
1412 let connecting = self.connecting.clone();
1413 let event_tx = self.event_tx.clone();
1414 let incoming_tx = self.incoming_tx.clone();
1415 let ws_transport = self.ws_transport.clone();
1416 let network = self.network.clone();
1417 let sem = self.eager_identity_sem.clone();
1418 let jitter_ms = self.eager_identity_jitter_ms;
1419 let inflight = self.identity_inflight.clone();
1420 let connect_ctx = EagerConnectCtx {
1421 peers: peers.clone(),
1422 by_device,
1423 ws_connections,
1424 peer_backoffs: peer_backoffs.clone(),
1425 connecting,
1426 event_tx,
1427 incoming_tx,
1428 ws_transport,
1429 network,
1430 };
1431
1432 let handle = tokio::spawn(async move {
1433 // Dedupe inside the task so a contended try-lock can never create
1434 // two active retry loops for the same peer.
1435 {
1436 let mut set = inflight.lock().await;
1437 if !set.insert(peer_id.clone()) {
1438 return;
1439 }
1440 }
1441
1442 // RFC 022 §8.1: stagger the first burst of eager hellos with a
1443 // bounded per-peer delay applied *before* acquiring the dial
1444 // permit, so a node joining a large mesh does not fire its first
1445 // `eager_identity_concurrency` hellos simultaneously. Waiting
1446 // before the semaphore (not after) means we never hold a dial slot
1447 // just to idle, and it spreads arrival at the semaphore so even the
1448 // very first dials are staggered. The delay is derived from the
1449 // peer id (no `rand` dependency); only this eager path waits — app
1450 // `send` never does.
1451 let delay = eager_jitter_delay(&peer_id, jitter_ms);
1452 if !delay.is_zero() {
1453 tokio::time::sleep(delay).await;
1454 }
1455
1456 loop {
1457 // Stop retrying once identity is learned or discovery says the
1458 // peer is gone/offline.
1459 let needs = {
1460 let map = peers.read().await;
1461 match map.get(&peer_id) {
1462 Some(s) => s.online && s.published_device_id().is_none(),
1463 None => false,
1464 }
1465 };
1466 if !needs {
1467 break;
1468 }
1469
1470 let permit = match sem.acquire().await {
1471 Ok(p) => p,
1472 Err(_) => break,
1473 };
1474
1475 // Build a temporary view with the same connection logic as
1476 // PeerRegistry::ensure_ws_connected (duplicated fields).
1477 let result = eager_connect_ws(&peer_id, &connect_ctx).await;
1478 drop(permit);
1479
1480 match result {
1481 Ok(()) => break,
1482 Err(SessionError::UnknownPeer(_) | SessionError::PeerOffline(_)) => break,
1483 Err(e) => {
1484 tracing::debug!(
1485 peer_id = %peer_id,
1486 error = %e,
1487 "session: eager identity dial failed; retrying"
1488 );
1489 }
1490 }
1491
1492 let retry_after = {
1493 let backoffs = peer_backoffs.read().await;
1494 backoffs
1495 .get(&peer_id)
1496 .map(ReconnectBackoff::retry_after)
1497 .unwrap_or_else(|| Duration::from_millis(100))
1498 .max(Duration::from_millis(25))
1499 };
1500 tokio::time::sleep(retry_after).await;
1501 }
1502
1503 let mut set = inflight.lock().await;
1504 set.remove(&peer_id);
1505 });
1506 let mut tasks = self.background_tasks.lock().unwrap();
1507 tasks.retain(|task| !task.is_finished());
1508 tasks.push(handle);
1509 }
1510}
1511
1512struct EagerConnectCtx<N: NetworkProvider + 'static> {
1513 peers: Arc<RwLock<HashMap<String, PeerState>>>,
1514 by_device: Arc<RwLock<HashMap<String, String>>>,
1515 ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1516 peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
1517 connecting: Arc<RwLock<HashSet<String>>>,
1518 event_tx: broadcast::Sender<PeerEvent>,
1519 incoming_tx: broadcast::Sender<IncomingMessage>,
1520 ws_transport: Arc<WebSocketTransport<N>>,
1521 network: Arc<N>,
1522}
1523
1524/// Connection path shared by eager identity (no send payload).
1525async fn eager_connect_ws<N: NetworkProvider + 'static>(
1526 peer_id: &str,
1527 ctx: &EagerConnectCtx<N>,
1528) -> Result<(), SessionError> {
1529 {
1530 let conns = ctx.ws_connections.read().await;
1531 if conns.contains_key(peer_id) {
1532 return Ok(());
1533 }
1534 }
1535
1536 let peer_addr = {
1537 let map = ctx.peers.read().await;
1538 let state = map
1539 .get(peer_id)
1540 .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
1541 if !state.online {
1542 return Err(SessionError::PeerOffline(peer_id.to_string()));
1543 }
1544 if state.published_device_id().is_some() {
1545 return Ok(());
1546 }
1547 PeerAddr {
1548 ip: Some(state.ip),
1549 hostname: state.name.clone(),
1550 dns_name: None,
1551 }
1552 };
1553
1554 {
1555 let backoffs = ctx.peer_backoffs.read().await;
1556 if let Some(backoff) = backoffs.get(peer_id) {
1557 if backoff.should_retry().is_none() {
1558 return Err(SessionError::ReconnectBackoff {
1559 retry_after: backoff.retry_after(),
1560 });
1561 }
1562 }
1563 }
1564
1565 {
1566 let mut connecting_g = ctx.connecting.write().await;
1567 if connecting_g.contains(peer_id) {
1568 return Err(SessionError::ConnectFailed(
1569 "connection already in progress".to_string(),
1570 ));
1571 }
1572 connecting_g.insert(peer_id.to_string());
1573 }
1574
1575 tracing::info!(peer_id = %peer_id, "session: eager identity connecting WS");
1576
1577 let connect_result = ctx.ws_transport.connect(&peer_addr).await;
1578
1579 {
1580 let mut connecting_g = ctx.connecting.write().await;
1581 connecting_g.remove(peer_id);
1582 }
1583
1584 let ws_stream = match connect_result {
1585 Ok(stream) => {
1586 let mut backoffs = ctx.peer_backoffs.write().await;
1587 backoffs
1588 .entry(peer_id.to_string())
1589 .or_insert_with(ReconnectBackoff::new)
1590 .success();
1591 stream
1592 }
1593 Err(e) => {
1594 let mut backoffs = ctx.peer_backoffs.write().await;
1595 backoffs
1596 .entry(peer_id.to_string())
1597 .or_insert_with(ReconnectBackoff::new)
1598 .failure();
1599 return Err(SessionError::ConnectFailed(e.to_string()));
1600 }
1601 };
1602
1603 // RFC 022 §7.5, dial side: same claimed-vs-dialed check as
1604 // `ensure_ws_connected` — an eager hello must never adopt an identity
1605 // from an answerer that is not the peer it dialed.
1606 if let Some(claimed) = ws_stream.remote_identity() {
1607 if claimed.tailscale_id != peer_id {
1608 tracing::warn!(
1609 peer_id = %peer_id,
1610 claimed = %claimed.tailscale_id,
1611 "session: eager dial answered as a different tailscale_id; dropping connection"
1612 );
1613 return Err(SessionError::ConnectFailed(format!(
1614 "hello identity mismatch: dialed {peer_id}, answerer claims {}",
1615 claimed.tailscale_id
1616 )));
1617 }
1618 }
1619
1620 let remote_identity = ws_stream.remote_identity().cloned();
1621
1622 let handle = spawn_connection_task(
1623 ws_stream,
1624 peer_id.to_string(),
1625 ConnectionDirection::Outbound,
1626 ctx.ws_connections.clone(),
1627 ctx.peers.clone(),
1628 ctx.event_tx.clone(),
1629 ctx.incoming_tx.clone(),
1630 );
1631
1632 if !install_connection(
1633 &ctx.ws_connections,
1634 &ctx.network.local_identity().tailscale_id,
1635 peer_id,
1636 handle,
1637 )
1638 .await
1639 {
1640 // The matching inbound connection won the cross-dial tie-break.
1641 return Ok(());
1642 }
1643
1644 {
1645 let mut map = ctx.peers.write().await;
1646 let mut by_dev = ctx.by_device.write().await;
1647 if let Some(state) = map.get_mut(peer_id) {
1648 state.ws_connected = true;
1649 }
1650 if let Some(identity) = remote_identity {
1651 let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
1652 emit_identity_outcomes(&ctx.event_tx, outcomes);
1653 }
1654 }
1655
1656 let _ = ctx
1657 .event_tx
1658 .send(PeerEvent::WsConnected(peer_id.to_string()));
1659 Ok(())
1660}
1661
1662// ---------------------------------------------------------------------------
1663// Helper: convert NetworkPeer to PeerState
1664// ---------------------------------------------------------------------------
1665
1666/// Convert a Layer 3 `NetworkPeer` to a Layer 5 `PeerState`.
1667///
1668/// Sets `ws_connected: false` by default — connections are managed by Layer 5,
1669/// not by Layer 3 discovery. `generation` must be supplied by the registry
1670/// (bumped per re-join of the same Tailscale id).
1671fn network_peer_to_state(peer: &NetworkPeer, generation: u64) -> PeerState {
1672 let connection_type = if let Some(ref relay) = peer.relay {
1673 format!("relay:{relay}")
1674 } else if peer.cur_addr.is_some() {
1675 "direct".to_string()
1676 } else {
1677 "unknown".to_string()
1678 };
1679
1680 PeerState {
1681 id: peer.id.clone(),
1682 generation,
1683 name: peer.hostname.clone(),
1684 ip: peer.ip,
1685 online: peer.online,
1686 ws_connected: false,
1687 connection_type,
1688 os: peer.os.clone(),
1689 last_seen: peer.last_seen.clone(),
1690 identity: None,
1691 identity_suppressed: false,
1692 }
1693}
1694
1695// ---------------------------------------------------------------------------
1696// Identity application (RFC 022 §7.7)
1697// ---------------------------------------------------------------------------
1698
1699/// Side effects from applying a hello identity to a registry entry.
1700#[derive(Debug)]
1701enum IdentityOutcome {
1702 /// Emit `PeerEvent::Identity` for this snapshot.
1703 Identity(PeerState),
1704 /// Retire a ghost entry (same ULID, offline holder) before the new claim.
1705 /// Carries the ghost's final state for the synthesized `Left` event.
1706 GhostLeft(PeerState),
1707 /// Emit `PeerEvent::Updated` — identity metadata (name/os) changed on a
1708 /// re-hello without the ULID changing (no second `identity`, RFC 022 §8).
1709 Updated(PeerState),
1710}
1711
1712/// Apply `identity` to the peer at `ts_id`, updating `by_device` under
1713/// first-wins / ghost-retire / rotation rules.
1714fn apply_identity(
1715 peers: &mut HashMap<String, PeerState>,
1716 by_device: &mut HashMap<String, String>,
1717 ts_id: &str,
1718 identity: PeerIdentity,
1719) -> Vec<IdentityOutcome> {
1720 let mut outcomes = Vec::new();
1721 let uid = identity.device_id.clone();
1722
1723 let Some(state) = peers.get(ts_id) else {
1724 return outcomes;
1725 };
1726
1727 // Rotation: same entry, different ULID already published.
1728 if let Some(prev) = state.published_device_id() {
1729 if prev != uid.as_str() {
1730 by_device.remove(prev);
1731 } else if !state.identity_suppressed {
1732 // Same ULID already published — refresh metadata silently:
1733 // RFC 022 §8 says later confirmations emit no second `identity`
1734 // (every WS reconnect re-hellos). A changed name/os surfaces as
1735 // `updated` instead.
1736 if let Some(s) = peers.get_mut(ts_id) {
1737 let changed = s.identity.as_ref() != Some(&identity);
1738 s.identity = Some(identity);
1739 s.identity_suppressed = false;
1740 if changed {
1741 outcomes.push(IdentityOutcome::Updated(s.clone()));
1742 }
1743 }
1744 return outcomes;
1745 }
1746 }
1747
1748 match by_device.get(&uid).cloned() {
1749 Some(holder) if holder == ts_id => {
1750 // Already the published owner — update identity block.
1751 if let Some(s) = peers.get_mut(ts_id) {
1752 s.identity = Some(identity);
1753 s.identity_suppressed = false;
1754 outcomes.push(IdentityOutcome::Identity(s.clone()));
1755 }
1756 }
1757 Some(holder) => {
1758 let holder_online = peers.get(&holder).map(|p| p.online).unwrap_or(false);
1759 if holder_online {
1760 // First-wins: store identity but suppress publication.
1761 tracing::warn!(
1762 device_id = %uid,
1763 holder = %holder,
1764 claimant = %ts_id,
1765 "session: duplicate-device-id — first-wins, suppressing claimant"
1766 );
1767 if let Some(s) = peers.get_mut(ts_id) {
1768 s.identity = Some(identity);
1769 s.identity_suppressed = true;
1770 // No Identity event for suppressed claim (deviceId stays null).
1771 }
1772 } else {
1773 // Ghost retire: offline holder loses the ULID.
1774 if let Some(mut ghost) = peers.remove(&holder) {
1775 ghost.online = false;
1776 ghost.ws_connected = false;
1777 outcomes.push(IdentityOutcome::GhostLeft(ghost));
1778 }
1779 by_device.insert(uid.clone(), ts_id.to_string());
1780 if let Some(s) = peers.get_mut(ts_id) {
1781 s.identity = Some(identity);
1782 s.identity_suppressed = false;
1783 outcomes.push(IdentityOutcome::Identity(s.clone()));
1784 }
1785 }
1786 }
1787 None => {
1788 by_device.insert(uid, ts_id.to_string());
1789 if let Some(s) = peers.get_mut(ts_id) {
1790 s.identity = Some(identity);
1791 s.identity_suppressed = false;
1792 outcomes.push(IdentityOutcome::Identity(s.clone()));
1793 }
1794 }
1795 }
1796
1797 outcomes
1798}
1799
1800fn emit_identity_outcomes(event_tx: &broadcast::Sender<PeerEvent>, outcomes: Vec<IdentityOutcome>) {
1801 for o in outcomes {
1802 match o {
1803 IdentityOutcome::GhostLeft(state) => {
1804 let _ = event_tx.send(PeerEvent::Left(state));
1805 }
1806 IdentityOutcome::Identity(state) => {
1807 let _ = event_tx.send(PeerEvent::Identity(state));
1808 }
1809 IdentityOutcome::Updated(state) => {
1810 let _ = event_tx.send(PeerEvent::Updated(state));
1811 }
1812 }
1813 }
1814}