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