ts_runtime/peer_tracker/mod.rs
1//! Peer delta update tracking.
2
3use std::{
4 collections::{HashMap, HashSet},
5 net::IpAddr,
6 sync::Arc,
7};
8
9use kameo::{
10 actor::ActorRef,
11 message::{Context, Message},
12 reply::ReplySender,
13};
14use tokio::sync::watch;
15use ts_control::{Node, UserId, UserProfile};
16use ts_transport::PeerId;
17
18use crate::{Error, dataplane::PeerDiscoKeyAdvertisement, env::Env, status::StatusNode};
19
20mod peer_db;
21
22pub use peer_db::PeerDb;
23
24/// Actor that tracks peer delta updates and emits new states.
25pub struct PeerTracker {
26 peer_db: PeerDb,
27 seen_state_update: bool,
28 pending_requests: Vec<Pending>,
29 /// Latest peer snapshot, published on every netmap update so embedders can watch for peer
30 /// changes ([`WatchNetmap`]).
31 peer_watch: watch::Sender<Vec<StatusNode>>,
32 /// Accumulated netmap user profiles (`MapResponse.UserProfiles`), keyed by user id, joined
33 /// against a node's [`Node::user_id`](ts_control::Node::user_id) to resolve the owning user's
34 /// login/display name for a [`WhoIs`](crate::status::WhoIs). Control sends these incrementally
35 /// (only new/changed profiles per response), so this map **accumulates** across updates rather
36 /// than being replaced — a peer upserted in one response may reference a profile delivered in an
37 /// earlier one.
38 user_profiles: HashMap<UserId, UserProfile>,
39 /// Tailnet-Lock (TKA) authority enforced at the peer-trust chokepoint, matching Go
40 /// `tkaFilterNetmapLocked`. Read on demand from a [`watch`] cell the control runner owns: when it
41 /// holds `Some` (a verified lock has been synced from control), enforcement is **active** — every
42 /// upserted peer must present a `key_signature` this authority authorizes, or it is dropped
43 /// (fail-closed), exactly as Go drops peers with a missing or failing signature. When it holds
44 /// `None` (no lock, or the lock was disabled) enforcement is **inactive** and every peer is
45 /// upserted, identical to pre-TKA behavior and to Go's `b.tka == nil` early return.
46 ///
47 /// A `watch::Receiver` (not the bus) is the transport on purpose: the authority is a single
48 /// security-critical state cell, and `watch` is last-write-wins, never-dropped, and ordered by
49 /// the control runner's own writes — so a disable (`None`) can never be reordered behind or
50 /// silently dropped before a stale `Some` (which a best-effort broadcast bus could do, leaving a
51 /// defunct lock enforcing forever). The control runner is the sole writer; we only ever read.
52 ///
53 /// The authority always passes through `VerifiedAumChain::verify` before the control runner
54 /// publishes it, so enforcement only engages on a chain we have cryptographically verified.
55 /// Connectivity now depends on `ts_tka` verifying genuinely-good signatures correctly (see
56 /// SECURITY.md). Self is structurally never filtered here (the self node never enters `peer_db` —
57 /// it is routed to the control runner's `self_node` cell), so a node cannot lock itself out of
58 /// its own netmap.
59 tka_authority: watch::Receiver<Option<Arc<ts_tka::Authority>>>,
60 env: Env,
61}
62
63impl PeerTracker {
64 fn peer_by_name_opt(&self, name: &str) -> Option<&Node> {
65 // Canonicalization (case + trailing dot) is handled inside the name index lookup.
66 self.peer_db.get(&name).map(|(_id, node)| node)
67 }
68
69 fn peer_by_tailnet_ip_opt(&self, ip: IpAddr) -> Option<&Node> {
70 self.peer_db.get(&ip).map(|(_id, node)| node)
71 }
72
73 /// Build the peer entries for a [`Status`](crate::Status) snapshot from the current peer db.
74 ///
75 /// Connectivity fields (`cur_addr`/`relay`) are left at their `from_node` defaults (`None`) here:
76 /// this is the live-watch/hot path and must stay magicsock-free and synchronous. The explicit
77 /// [`GetStatus`] snapshot enriches them ([`status_peers_with_ids`](Self::status_peers_with_ids)).
78 fn status_peers(&self) -> Vec<StatusNode> {
79 self.peer_db
80 .peers()
81 .values()
82 .map(StatusNode::from_node)
83 .collect()
84 }
85
86 /// Like [`status_peers`](Self::status_peers) but pairs each entry with its [`PeerId`], so the
87 /// caller can join per-peer connectivity (the direct manager's `best_addrs`, keyed by `PeerId`)
88 /// onto the `StatusNode` before returning it. Order is unspecified (a `HashMap` walk).
89 fn status_peers_with_ids(&self) -> Vec<(PeerId, StatusNode)> {
90 self.peer_db
91 .peers()
92 .iter()
93 .map(|(id, node)| (*id, StatusNode::from_node(node)))
94 .collect()
95 }
96
97 fn whois_opt(&self, addr: std::net::SocketAddr) -> Option<crate::status::WhoIs> {
98 let ip = crate::status::whois_addr(addr);
99 let node = self.peer_by_tailnet_ip_opt(ip).cloned()?;
100 // Join the node's owning user id against the accumulated UserProfiles table to resolve a
101 // login/display name. `None` when control sent no profile for that user (e.g. tagged nodes
102 // with no human owner, or a profile not yet delivered).
103 let user = self.resolve_user(node.user_id);
104 Some(crate::status::WhoIs::from_node_with_user(node, user))
105 }
106
107 /// Resolve a user id to its best display label from the accumulated profile table.
108 fn resolve_user(&self, user_id: UserId) -> Option<String> {
109 self.user_profiles
110 .get(&user_id)
111 .and_then(UserProfile::best_label)
112 }
113
114 /// Whether `node` may be admitted to the peer db under Tailnet Lock, matching Go
115 /// `tkaFilterNetmapLocked`'s per-peer verdict (drop unsigned / failed-signature peers).
116 ///
117 /// This consults the live [`tka_authority`](Self::tka_authority) cell on each call (one `borrow`,
118 /// held only for the duration of the verdict). For a `Full` resync — which checks every peer —
119 /// prefer [`tka_authority_snapshot`](Self::tka_authority_snapshot) +
120 /// [`tka_snapshot_admits`](Self::tka_snapshot_admits) to borrow once and verify each peer a single
121 /// time; this method is the convenience wrapper for the single-peer (`Delta`/patch) sites.
122 ///
123 /// Fail-closed and gated:
124 /// - No authority ⇒ no lock synced ⇒ always admit (Go's `b.tka == nil` early return; identical to
125 /// pre-TKA behavior).
126 /// - **Empty trusted-key state** ⇒ always admit (logged at `error!` — see
127 /// [`tka_snapshot_admits`](Self::tka_snapshot_admits) for the full rationale).
128 /// - Authority present + peer carries a `key_signature` the authority authorizes for the peer's
129 /// node key ⇒ admit.
130 /// - Authority present + signature missing or unauthorized/invalid ⇒ **drop** (Go drops peers
131 /// with a missing signature or failed `NodeKeyAuthorized` under tailnet lock).
132 fn tka_admits(&self, node: &Node) -> bool {
133 // Single-peer sites (`Delta`/patch) only need the admit bool; the rotation details are used
134 // exclusively by the cross-peer `Full` filter (rotation obsolescence is whole-netmap).
135 Self::tka_snapshot_admits(self.tka_authority.borrow().as_deref(), node).admitted
136 }
137
138 /// Borrow the current TKA authority once (cloning the cheap `Arc`) for a batch verdict. Returns
139 /// `None` when no lock is synced (admit-all). Used by the `Full` path so a netmap of N peers
140 /// reads the cell once and runs at most one signature verify per peer (not two).
141 fn tka_authority_snapshot(&self) -> Option<Arc<ts_tka::Authority>> {
142 self.tka_authority.borrow().clone()
143 }
144
145 /// The per-peer Tailnet-Lock verdict against an already-borrowed `authority` snapshot. Factored
146 /// out so both the single-peer [`tka_admits`](Self::tka_admits) and the `Full` batch path share
147 /// one verdict implementation (no divergence) while the batch path verifies each peer exactly
148 /// once.
149 ///
150 /// Returns whether the peer is admitted AND, for an admitted peer signed by a rotation chain, the
151 /// [`RotationDetails`](ts_tka::RotationDetails) of that chain — so the `Full` path can run the
152 /// cross-peer rotation filter (Go's `rotationTracker`) without a second verify per peer. A peer
153 /// that is dropped, unsigned, or signed by a non-rotation chain carries `rotation == None`.
154 ///
155 /// Never logs key/signature bytes — only the `stable_id` and the `TkaError` Display (static
156 /// descriptors). One documented parity gap remains vs Go (under-enforcement, in PARITY_ROADMAP):
157 /// no `UnsignedPeerAPIOnly` exemption (our node model lacks the field).
158 fn tka_snapshot_admits(authority: Option<&ts_tka::Authority>, node: &Node) -> TkaVerdict {
159 let Some(auth) = authority else {
160 return TkaVerdict::admit();
161 };
162
163 // Brick-guard: an authority with no trusted keys would drop every peer. A verified chain is
164 // structurally guaranteed ≥1 key (genesis rejects an empty key set, and the last key cannot
165 // be removed), so reaching here means a `ts_tka` invariant was violated — admit rather than
166 // black-hole the whole netmap, and log at `error!` because it signals a real bug, not an
167 // expected runtime input. This is OUR fail-safe, not a Go behavior. NOTE: it only catches the
168 // empty-keyset shape; a non-empty authority that authorizes none of the offered peers still
169 // (correctly) drops them — that is what a lock that revoked everyone means. The
170 // "authorized-zero-peers" isolation case is surfaced separately by the caller.
171 if auth.state().keys.is_empty() {
172 tracing::error!(
173 "TKA: authority has an empty trusted-key set (verified chains never do — likely a \
174 ts_tka bug); not enforcing (admitting all) to avoid isolating the node"
175 );
176 return TkaVerdict::admit();
177 }
178
179 if node.key_signature.is_empty() {
180 tracing::warn!(
181 stable_id = ?node.stable_id,
182 "TKA: dropping unsigned peer under tailnet lock"
183 );
184 return TkaVerdict::drop();
185 }
186
187 match auth.node_key_authorized_with_details(&node.node_key.to_bytes(), &node.key_signature)
188 {
189 Ok(rotation) => {
190 tracing::debug!(stable_id = ?node.stable_id, "TKA: peer node-key authorized");
191 TkaVerdict {
192 admitted: true,
193 rotation,
194 }
195 }
196 Err(e) => {
197 tracing::warn!(
198 stable_id = ?node.stable_id,
199 error = %e,
200 "TKA: dropping peer with unauthorized node key"
201 );
202 TkaVerdict::drop()
203 }
204 }
205 }
206}
207
208/// The outcome of a per-peer Tailnet-Lock check: whether the peer is admitted, plus (for an admitted
209/// peer signed by a rotation chain) the chain's [`RotationDetails`](ts_tka::RotationDetails) so the
210/// `Full` path can run the cross-peer rotation filter from the SAME verify pass (no second verify).
211struct TkaVerdict {
212 admitted: bool,
213 rotation: Option<ts_tka::RotationDetails>,
214}
215
216impl TkaVerdict {
217 /// Admitted, no rotation details (no lock / brick-guard / non-rotation signature).
218 fn admit() -> Self {
219 Self {
220 admitted: true,
221 rotation: None,
222 }
223 }
224 /// Dropped.
225 fn drop() -> Self {
226 Self {
227 admitted: false,
228 rotation: None,
229 }
230 }
231}
232
233/// Cross-peer rotation-obsolescence tracker, mirroring Go `ipnlocal.rotationTracker`. Fed the
234/// [`RotationDetails`](ts_tka::RotationDetails) of every admitted, rotation-signed peer in a `Full`
235/// netmap; [`obsolete_keys`](Self::obsolete_keys) then returns the node keys to drop on top of the
236/// per-peer verdict. Two rules (Go `tkaFilterNetmapLocked` + `rotationTracker.obsoleteKeys`):
237///
238/// 1. Every prior node key named in any rotation chain is obsolete (a newer chain rotated it away).
239/// 2. Among `Direct`-rooted chains sharing one wrapping pubkey (a clone signal), only the
240/// longest-chain peer survives; if the two longest are tied, ALL in that group are dropped (we
241/// cannot tell which is the latest, so reject for safety). `Credential`-rooted chains are exempt
242/// from rule 2 — several nodes can legitimately join under one reusable auth key (same wrapping
243/// pubkey), so sharing it is not a clone signal there. (Rule 1 still applies to them.)
244///
245/// Node keys are tracked as raw `Vec<u8>` (the verified 32-byte node-public bytes).
246#[derive(Default)]
247struct RotationTracker {
248 obsolete: HashSet<Vec<u8>>,
249 by_wrapping_key: HashMap<Vec<u8>, Vec<SigRotation>>,
250}
251
252/// One admitted peer's rotation entry within a wrapping-key group.
253struct SigRotation {
254 node_key: Vec<u8>,
255 num_prev_keys: usize,
256}
257
258impl RotationTracker {
259 /// Record an admitted peer `node_key` and its rotation `details` (Go `addRotationDetails`).
260 fn add(&mut self, node_key: Vec<u8>, details: &ts_tka::RotationDetails) {
261 // Rule 1: every prior key is obsolete — applied for ALL chains (incl. credential-rooted),
262 // matching Go's ungated `obsolete.AddSlice(d.PrevNodeKeys)`.
263 self.obsolete.extend(details.prev_node_keys.iter().cloned());
264 // Rule 2 (clone-uniqueness) is gated to Direct-rooted chains only.
265 if details.initial_sig_kind != ts_tka::SigKind::Direct {
266 return;
267 }
268 self.by_wrapping_key
269 .entry(details.initial_wrapping_pubkey.clone())
270 .or_default()
271 .push(SigRotation {
272 node_key,
273 num_prev_keys: details.prev_node_keys.len(),
274 });
275 }
276
277 /// Compute the full obsolete node-key set (Go `rotationTracker.obsoleteKeys`). Processes each
278 /// wrapping-key group, mutating the shared `obsolete` set as it goes (so a key obsoleted by one
279 /// group is seen as obsolete by later groups via the `retain` below — Go's
280 /// `slices.DeleteFunc(... Contains)`). Group iteration order (a `HashMap` drain) is
281 /// nondeterministic, but the result is order-INDEPENDENT: this only ever *inserts* into
282 /// `obsolete` (never removes), and rule 1 already obsoleted every prior key before this loop, so
283 /// the final set is a union that does not depend on which group runs first (as in Go).
284 fn obsolete_keys(mut self) -> HashSet<Vec<u8>> {
285 // Drain only the group map so the loop can mutate `self.obsolete` without aliasing it; the
286 // shared `obsolete` set itself is NOT drained, preserving the cross-group visibility above.
287 let groups: Vec<Vec<SigRotation>> = self.by_wrapping_key.drain().map(|(_k, v)| v).collect();
288 for mut group in groups {
289 // Drop entries already obsoleted (rotated away) by another chain.
290 group.retain(|rd| !self.obsolete.contains(&rd.node_key));
291 if group.is_empty() {
292 continue;
293 }
294 // Longest chain (most prior keys) is the newest ⇒ the survivor; sort decreasing.
295 // `sort_by_key` is stable (like Go's `SortStableFunc`); `Reverse` gives descending order.
296 group.sort_by_key(|rd| core::cmp::Reverse(rd.num_prev_keys));
297 if group.len() >= 2 && group[0].num_prev_keys == group[1].num_prev_keys {
298 // Tie for longest ⇒ cannot disambiguate the latest ⇒ drop the WHOLE group.
299 tracing::warn!(
300 "TKA: multiple peers share a wrapping key with equal rotation depth; dropping all (cannot determine the latest)"
301 );
302 for rd in &group {
303 self.obsolete.insert(rd.node_key.clone());
304 }
305 } else {
306 // Only the longest-chain peer survives; the rest are obsolete.
307 for rd in &group[1..] {
308 self.obsolete.insert(rd.node_key.clone());
309 }
310 }
311 }
312 self.obsolete
313 }
314}
315
316impl kameo::Actor for PeerTracker {
317 /// `(env, tka_authority)`: the bus/keys env, plus the read end of the control runner's TKA
318 /// enforcement-authority cell (Go `tkaFilterNetmapLocked`). The control runner is the sole
319 /// writer; it publishes the verified `Authority` after a successful `/machine/tka/sync` and
320 /// `None` when the lock is disabled. A `watch` cell (not a bus message) so the latest value is
321 /// always readable on demand, never dropped, and never reordered (see the control runner's
322 /// `tka_authority` cell).
323 type Args = (Env, watch::Receiver<Option<Arc<ts_tka::Authority>>>);
324 type Error = Error;
325
326 async fn on_start(
327 (env, tka_authority): Self::Args,
328 slf: ActorRef<Self>,
329 ) -> Result<Self, Self::Error> {
330 env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
331 env.subscribe::<PeerDiscoKeyAdvertisement>(&slf).await?;
332
333 let (peer_watch, _) = watch::channel(Vec::new());
334
335 Ok(Self {
336 peer_db: PeerDb::default(),
337 pending_requests: Default::default(),
338 seen_state_update: false,
339 peer_watch,
340 user_profiles: HashMap::new(),
341 // The cell starts `None` (no lock synced ⇒ enforcement inactive, admit all, matching
342 // Go's `b.tka == nil`); the control runner flips it to `Some` on the first sync.
343 tka_authority,
344 env,
345 })
346 }
347}
348
349enum Pending {
350 PeerByName(PeerByName, ReplySender<Option<Node>>),
351 AcceptedRoute(PeerByAcceptedRoute, ReplySender<Vec<Node>>),
352 TailnetIp(PeerByTailnetIp, ReplySender<Option<Node>>),
353 Status(ReplySender<Vec<(PeerId, StatusNode)>>),
354 WhoIs(Whois, ReplySender<Option<crate::status::WhoIs>>),
355}
356
357// For messages with arguments, a struct is generated with the args as fields. They aren't
358// documented, and we can't apply attributes directly to the fields. Hence, wrap in a module where
359// docs are turned off everywhere.
360#[allow(missing_docs)]
361mod msg_impl {
362 use std::net::IpAddr;
363
364 use kameo::prelude::DelegatedReply;
365
366 use super::*;
367
368 #[kameo::messages]
369 impl PeerTracker {
370 /// Lookup a peer by name.
371 ///
372 /// Waits until we've received at least one peer update from control.
373 #[message(ctx)]
374 pub async fn peer_by_name(
375 &mut self,
376 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
377 name: String,
378 ) -> DelegatedReply<Option<Node>> {
379 let (deleg, sender) = ctx.reply_sender();
380 let Some(sender) = sender else { return deleg };
381
382 if !self.seen_state_update {
383 tracing::debug!(query = name, "no peer state seen yet, queueing request");
384
385 self.pending_requests
386 .push(Pending::PeerByName(PeerByName { name }, sender));
387
388 return deleg;
389 }
390
391 sender.send(self.peer_by_name_opt(&name).cloned());
392
393 deleg
394 }
395
396 /// Lookup all peers that accept packets addressed to the given IP.
397 ///
398 /// This includes the peer's tailnet address and any subnet routes it provides. Only
399 /// the peers with the most specific subnet route match that covers `ip` will be
400 /// returned.
401 ///
402 /// E.g., suppose:
403 ///
404 /// - We're querying for `10.1.2.3`
405 /// - `PeerA` and `PeerB` have accepted routes for `10.1.2.0/24`
406 /// - `PeerC` has an accepted route for `10.1.0.0/16`
407 ///
408 /// Only `PeerA` and `PeerB` will be returned, since they have the most specific
409 /// prefix match.
410 #[message(ctx)]
411 pub fn peer_by_accepted_route(
412 &mut self,
413 ctx: &mut Context<Self, DelegatedReply<Vec<Node>>>,
414 ip: IpAddr,
415 ) -> DelegatedReply<Vec<Node>> {
416 let (deleg, sender) = ctx.reply_sender();
417 let Some(sender) = sender else { return deleg };
418
419 if !self.seen_state_update {
420 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
421
422 self.pending_requests
423 .push(Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, sender));
424
425 return deleg;
426 }
427
428 sender.send(
429 self.peer_db
430 .get_route(ip.into())
431 .map(|(_id, node)| node.clone())
432 .collect(),
433 );
434
435 deleg
436 }
437
438 /// Lookup the peer that has the given tailnet IP address.
439 #[message(ctx)]
440 pub fn peer_by_tailnet_ip(
441 &mut self,
442 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
443 ip: IpAddr,
444 ) -> DelegatedReply<Option<Node>> {
445 let (deleg, sender) = ctx.reply_sender();
446 let Some(sender) = sender else { return deleg };
447
448 if !self.seen_state_update {
449 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
450
451 self.pending_requests
452 .push(Pending::TailnetIp(PeerByTailnetIp { ip }, sender));
453
454 return deleg;
455 }
456
457 sender.send(self.peer_by_tailnet_ip_opt(ip).cloned());
458
459 deleg
460 }
461
462 /// Build the peer entries of a [`Status`](crate::Status) snapshot, each paired with its
463 /// [`PeerId`] so [`Runtime::status`](crate::Runtime::status) can join per-peer connectivity
464 /// (`cur_addr`/`relay`) from the direct manager before returning. The self node is *not*
465 /// included here (it lives in the control runner); `Runtime::status` combines both and drops
466 /// the ids.
467 ///
468 /// Waits until we've received at least one peer update from control.
469 #[message(ctx)]
470 pub fn get_status(
471 &mut self,
472 ctx: &mut Context<Self, DelegatedReply<Vec<(PeerId, StatusNode)>>>,
473 ) -> DelegatedReply<Vec<(PeerId, StatusNode)>> {
474 let (deleg, sender) = ctx.reply_sender();
475 let Some(sender) = sender else { return deleg };
476
477 if !self.seen_state_update {
478 tracing::debug!("no peer state seen yet, queueing status request");
479 self.pending_requests.push(Pending::Status(sender));
480 return deleg;
481 }
482
483 sender.send(self.status_peers_with_ids());
484
485 deleg
486 }
487
488 /// Return every known peer's full domain [`Node`] (not the lossy [`StatusNode`]).
489 ///
490 /// Used by [`Runtime::file_targets`](crate::Runtime::file_targets), which needs the full node
491 /// (peerAPI address, owning user id, cap map) to compute Taildrop send targets. The self node
492 /// is not included (it lives in the control runner). Returns empty before the first netmap —
493 /// the natural "not connected yet" analog (an immediate answer, no queueing needed: callers
494 /// that need a populated list await `Running` first).
495 #[message]
496 pub fn all_peers(&self) -> Vec<Node> {
497 self.peer_db.peers().values().cloned().collect()
498 }
499
500 /// Resolve which node owns a tailnet source address.
501 ///
502 /// Maps the source IP of `addr` to the owning node via the tailnet-IP index, returning a
503 /// [`WhoIs`](crate::WhoIs). The port is ignored (a tailnet IP uniquely identifies a node).
504 ///
505 /// The resulting [`WhoIs`](crate::WhoIs) carries no user/login or capability data: this
506 /// fork's domain [`Node`] does not retain those wire fields. See the
507 /// [`status`](crate::status) module docs for the gap.
508 ///
509 /// Waits until we've received at least one peer update from control.
510 #[message(ctx)]
511 pub fn whois(
512 &mut self,
513 ctx: &mut Context<Self, DelegatedReply<Option<crate::status::WhoIs>>>,
514 addr: std::net::SocketAddr,
515 ) -> DelegatedReply<Option<crate::status::WhoIs>> {
516 let (deleg, sender) = ctx.reply_sender();
517 let Some(sender) = sender else { return deleg };
518
519 if !self.seen_state_update {
520 tracing::debug!(query = %addr, "no peer state seen yet, queueing whois request");
521 self.pending_requests
522 .push(Pending::WhoIs(Whois { addr }, sender));
523 return deleg;
524 }
525
526 sender.send(self.whois_opt(addr));
527
528 deleg
529 }
530
531 /// Subscribe to netmap peer-change events.
532 ///
533 /// Returns a [`watch::Receiver`] whose value is the current set of peer
534 /// [`StatusNode`]s, updated on every netmap state update from control. Embedders can await
535 /// changes via [`watch::Receiver::changed`] to react to peers joining, leaving, or changing.
536 ///
537 /// The receiver's initial value is the peer set at subscription time (empty before the
538 /// first netmap update). This is a peer-only view; combine with the self node from
539 /// [`Runtime::status`](crate::Runtime::status) when a full snapshot is needed.
540 #[message(derive(Clone))]
541 pub fn watch_netmap(&self) -> watch::Receiver<Vec<StatusNode>> {
542 self.peer_watch.subscribe()
543 }
544 }
545}
546
547pub use msg_impl::*;
548
549#[derive(Debug, Clone)]
550pub(crate) struct PeerState {
551 #[allow(unused)]
552 pub deletions: HashSet<PeerId>,
553 #[allow(unused)]
554 pub upserts: HashSet<PeerId>,
555 pub peers: Arc<PeerDb>,
556}
557
558impl Message<Arc<ts_control::StateUpdate>> for PeerTracker {
559 type Reply = ();
560
561 async fn handle(
562 &mut self,
563 msg: Arc<ts_control::StateUpdate>,
564 _ctx: &mut Context<Self, Self::Reply>,
565 ) {
566 // Accumulate user profiles first — control sends them incrementally and a response may
567 // carry profiles with no peer delta (or peers that reference a profile from an earlier
568 // response), so this must happen before the no-peer-update early return below.
569 for profile in &msg.user_profiles {
570 self.user_profiles.insert(profile.id, profile.clone());
571 }
572
573 // Apply the standalone online/last-seen delta maps (channels C/D, `MapResponse.OnlineChange`
574 // / `PeerSeenChange`). These arrive keyed by control node id and may ride a response that
575 // carries NO `peer_update` (a bare online flip is the common case), so they must be applied
576 // *before* the no-peer-update early return — otherwise online status freezes at the last
577 // full-node/patch value. Each entry only ever *sets* a value (never back to unknown).
578 // Wall clock for a `PeerSeenChange: true` (Go uses `clock.Now()`). chrono is built without
579 // its `clock` feature in this workspace, so derive it from `SystemTime` the same way the
580 // control runner / ssh-policy paths do (unix secs → `DateTime::from_timestamp`).
581 let now = std::time::SystemTime::now()
582 .duration_since(std::time::UNIX_EPOCH)
583 .ok()
584 .and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
585 .unwrap_or_default();
586 let liveness_changed =
587 self.apply_liveness_changes(&msg.online_change, &msg.peer_seen_change, now);
588
589 if msg.peer_update.is_none() && msg.peer_patches.is_empty() {
590 // No peer set or patch this response. If a liveness delta still mutated the netmap,
591 // publish the refreshed snapshot so watchers (and `GetStatus`) see the new online state.
592 if liveness_changed {
593 self.service_pending_requests();
594 self.peer_watch.send_replace(self.status_peers());
595 if let Err(e) = self
596 .env
597 .publish(Arc::new(PeerState {
598 upserts: HashSet::default(),
599 deletions: HashSet::default(),
600 peers: Arc::new(self.peer_db.clone()),
601 }))
602 .await
603 {
604 tracing::error!(error = %e, "publishing liveness-only peer state update");
605 }
606 }
607 return;
608 }
609
610 // Apply the whole-node peer set (if any) FIRST, then the field-level patches on top —
611 // mirroring Go's `controlclient` order (`Peers*` then `PeersChangedPatch`). A response may
612 // carry either, both, or (with a liveness-only delta) neither. Merge the upsert/deletion sets
613 // so the published `PeerState` reflects every node touched by both passes; a node both
614 // upserted by the set and patched stays in `upserts` (the patch removes it from `deletions`).
615 let (mut upserts, mut deletions) = msg
616 .peer_update
617 .as_ref()
618 .map(|u| self.apply_peer_update(u))
619 .unwrap_or_default();
620
621 if !msg.peer_patches.is_empty() {
622 let (patch_upserts, patch_deletions) = self.apply_peer_patches(&msg.peer_patches);
623 // A patch can evict a node the set just upserted (TKA rejection after key rotation), or
624 // re-admit/patch one not in the set — reconcile so each id lands in exactly one set.
625 for id in &patch_upserts {
626 deletions.remove(id);
627 }
628 for id in &patch_deletions {
629 upserts.remove(id);
630 }
631 upserts.extend(patch_upserts);
632 deletions.extend(patch_deletions);
633 }
634
635 tracing::debug!(
636 n_upsert = upserts.len(),
637 n_delete = deletions.len(),
638 peer_count = self.peer_db.peers().len(),
639 "new peer state"
640 );
641
642 self.service_pending_requests();
643
644 // Publish the latest peer snapshot to netmap watchers. `send_replace` keeps the receiver's
645 // value current even when there are no subscribers, so a late subscriber sees fresh state.
646 self.peer_watch.send_replace(self.status_peers());
647
648 if let Err(e) = self
649 .env
650 .publish(Arc::new(PeerState {
651 upserts,
652 deletions,
653 peers: Arc::new(self.peer_db.clone()),
654 }))
655 .await
656 {
657 tracing::error!(error = %e, "publishing peer state update");
658 }
659 }
660}
661
662impl Message<PeerDiscoKeyAdvertisement> for PeerTracker {
663 type Reply = ();
664
665 async fn handle(
666 &mut self,
667 msg: PeerDiscoKeyAdvertisement,
668 _ctx: &mut Context<Self, Self::Reply>,
669 ) {
670 if !self.learn_disco_key(msg.peer, msg.key) {
671 return;
672 }
673
674 // The key changed, so republish: the direct-path machinery resolves a peer's disco key out
675 // of the published `PeerState` snapshot (`direct::DiscoPeerLookup`), which is the whole
676 // point of learning it — it is what lets disco reach this peer without waiting for a
677 // netmap update. Go does the equivalent by writing the key straight into the magicsock
678 // endpoint and re-keying its peer map.
679 self.peer_watch.send_replace(self.status_peers());
680
681 if let Err(e) = self
682 .env
683 .publish(Arc::new(PeerState {
684 upserts: HashSet::from_iter([msg.peer]),
685 deletions: HashSet::default(),
686 peers: Arc::new(self.peer_db.clone()),
687 }))
688 .await
689 {
690 tracing::error!(error = %e, "publishing peer state after a TSMP disco-key advertisement");
691 }
692 }
693}
694
695/// Ask the peer tracker to re-broadcast its current peer snapshot on the bus, without any peer
696/// change. Sent after a runtime preference change so the route updater and source filter (both
697/// `Arc<PeerState>` subscribers) re-resolve against the new value immediately, rather than waiting
698/// for the next netmap update: `Device::set_exit_node` (new exit-node selector) and
699/// `Device::set_accept_routes` (new accept-routes flag) both send it.
700#[derive(Debug, Clone, Copy)]
701pub struct RepublishState;
702
703impl Message<RepublishState> for PeerTracker {
704 type Reply = ();
705
706 async fn handle(&mut self, _msg: RepublishState, _ctx: &mut Context<Self, Self::Reply>) {
707 // An empty upsert/deletion set: this is a re-broadcast of the unchanged peer set, not a
708 // delta. Subscribers recompute their routes/filters against the current peers and the
709 // (just-updated) runtime preferences (exit-node selector, accept-routes flag).
710 if let Err(e) = self
711 .env
712 .publish(Arc::new(PeerState {
713 upserts: HashSet::default(),
714 deletions: HashSet::default(),
715 peers: Arc::new(self.peer_db.clone()),
716 }))
717 .await
718 {
719 tracing::error!(error = %e, "re-publishing peer state after a runtime preference change");
720 }
721 }
722}
723
724impl PeerTracker {
725 /// Learn a peer's disco key from a TSMP disco-key advertisement, returning whether the peer db
726 /// actually changed.
727 ///
728 /// Go [`magicsock.Conn.HandleDiscoKeyAdvertisement`], reduced to the state this fork keeps:
729 /// Go stores the learned key on the magicsock endpoint and re-keys its peer map, whereas here
730 /// the peer db's `disco_key` (and its disco index) *is* the live lookup every direct-path
731 /// consumer reads. The three refusals are Go's, in Go's order:
732 ///
733 /// 1. **A zero key is never learned.** Go checks it twice — `tstun` publishes only
734 /// `if !Key.IsZero()`, and `HandleDiscoKeyAdvertisement` rejects it again. The dataplane
735 /// already dropped it here too; this is the second check, kept because the cost of getting
736 /// it wrong is a peer bound to an unusable key.
737 /// 2. **An unknown peer is ignored** (Go: "endpoint not found for node"). An advertisement
738 /// never creates a peer — only control does — so one that arrives before or after the
739 /// peer's netmap entry is a no-op, exactly like a `PeersChangedPatch` for an unknown node.
740 /// 3. **An unchanged key is a no-op**, so a peer re-advertising the key we already hold costs
741 /// no upsert and no republish (Go counts this as
742 /// `magicsock_tsmp_disco_key_advertisement_unchanged` and returns).
743 ///
744 /// The tailnet-lock gate is deliberately *not* re-run: unlike a `PeersChangedPatch`, an
745 /// advertisement cannot touch the node key or its TKA signature — only the disco key — so the
746 /// peer-trust decision that admitted this node is unchanged by definition.
747 ///
748 /// [`magicsock.Conn.HandleDiscoKeyAdvertisement`]: https://github.com/tailscale/tailscale/blob/main/wgengine/magicsock/magicsock.go
749 fn learn_disco_key(&mut self, peer: PeerId, key: ts_keys::DiscoPublicKey) -> bool {
750 if key.to_bytes() == [0u8; ts_keys::DiscoPublicKey::KEY_LEN_BYTES] {
751 tracing::debug!(?peer, "TSMP-advertised disco key is the zero key; ignoring");
752 return false;
753 }
754
755 let Some((_id, existing)) = self.peer_db.get(&peer) else {
756 tracing::debug!(
757 ?peer,
758 "TSMP disco-key advertisement for unknown peer; ignoring"
759 );
760 return false;
761 };
762
763 if existing.disco_key == Some(key) {
764 tracing::trace!(?peer, "TSMP-advertised disco key is unchanged");
765 return false;
766 }
767
768 let mut node = existing.clone();
769 node.disco_key = Some(key);
770 self.peer_db.upsert(&node);
771
772 tracing::info!(
773 ?peer,
774 stable_id = ?node.stable_id,
775 %key,
776 "learned peer disco key from a TSMP advertisement"
777 );
778
779 true
780 }
781
782 /// Apply a single [`PeerUpdate`](ts_control::PeerUpdate) to the peer db, enforcing the
783 /// Tailnet-Lock peer-trust chokepoint ([`tka_admits`](Self::tka_admits)) at every upsert site.
784 ///
785 /// This is the **single source of truth** for the peer-trust enforcement loop: the actor's
786 /// netmap [`handle`](Message::handle) calls it, and so do the TKA enforcement tests, so the two
787 /// real upsert sites (`Full` and `Delta { upsert }`) cannot diverge from what is tested.
788 ///
789 /// Returns `(upserts, deletions)` — the [`PeerId`]s touched — for downstream bookkeeping.
790 fn apply_peer_update(
791 &mut self,
792 peer_update: &ts_control::PeerUpdate,
793 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
794 let mut upserts = HashSet::default();
795 let mut deletions = HashSet::default();
796
797 match peer_update {
798 ts_control::PeerUpdate::Full(new_nodes) => {
799 tracing::trace!("full peer update");
800
801 // Borrow the authority ONCE for the whole batch and verify each peer EXACTLY once
802 // (Go runs `tkaFilterNetmapLocked` once over the assembled netmap; an earlier draft
803 // verified every peer twice — once for `retained_ids`, once in the upsert loop —
804 // doubling the ed25519 cost on the hot resync path). The per-node verdict vector
805 // `admits` is computed once and drives both the `retain` (evict revoked peers, keyed
806 // by stable_id) and the upsert loop (skip rejected peers, by the node's OWN verdict).
807 // Keeping a per-node verdict (not just a stable_id set) means a node whose own
808 // signature fails is never admitted on the strength of a different node that happens
809 // to share its stable_id — matching the old per-node re-verify for that degenerate
810 // (malformed-control) input.
811 //
812 // Revocation evicts: a peer re-included with a now-invalid/missing signature under an
813 // active authority fails its verdict, so it is excluded from `retained_ids` and
814 // `retain` drops the stale (previously-admitted) entry. With no authority the snapshot
815 // is `None`, so every node passes — byte-for-byte the pre-TKA behavior (no regression).
816 let authority = self.tka_authority_snapshot();
817 let verdicts = new_nodes
818 .iter()
819 .map(|node| Self::tka_snapshot_admits(authority.as_deref(), node))
820 .collect::<Vec<_>>();
821
822 // Cross-peer rotation filter (Go `rotationTracker`): from the SAME verify pass above,
823 // feed every admitted, rotation-signed peer's details to the tracker, then drop any
824 // peer presenting a node key a newer rotation has superseded (or a tied clone). This
825 // is whole-netmap by nature — one peer's chain obsoletes another's key — so it lives
826 // here, not in the per-peer verdict, matching Go's single pass over `nm.Peers`.
827 let mut rotation = RotationTracker::default();
828 for (node, verdict) in new_nodes.iter().zip(&verdicts) {
829 if verdict.admitted
830 && let Some(details) = &verdict.rotation
831 {
832 rotation.add(node.node_key.to_bytes().to_vec(), details);
833 }
834 }
835 let obsolete = rotation.obsolete_keys();
836
837 // Final per-node keep verdict: admitted by the per-peer check AND not rotation-obsolete.
838 // Drives both the `retain` (evict) and the upsert loop, so a node whose own signature
839 // fails — or whose key was rotated away — is never admitted on the strength of a
840 // stable_id twin.
841 let keep = new_nodes
842 .iter()
843 .zip(&verdicts)
844 .map(|(node, v)| {
845 // `contains` takes `&[u8]` (HashSet<Vec<u8>> borrows as a slice) — no alloc.
846 v.admitted && !obsolete.contains(&node.node_key.to_bytes()[..])
847 })
848 .collect::<Vec<bool>>();
849
850 // `retained_ids` is the set of stable_ids that survive (drives `retain` to evict the
851 // rest). It must agree with what the upsert loop below will leave in the db. Control
852 // should never send two distinct nodes with the same `stable_id` in one `Full`, but if
853 // it does, `peer_db.upsert` is last-writer-wins on `stable_id`, so the db ends holding
854 // the LAST kept node for that id. Build `retained_ids` from kept nodes only — a
855 // stable_id is retained iff at least one of its (possibly duplicate) nodes is kept, so
856 // the upsert loop's last-kept node lands and `retain` never evicts a just-upserted id.
857 let retained_ids = new_nodes
858 .iter()
859 .zip(keep.iter().copied())
860 .filter(|(_, k)| *k)
861 .map(|(node, _)| &node.stable_id)
862 .collect::<HashSet<_>>();
863
864 // Isolation diagnostic: an ACTIVE lock that authorized none of the offered peers
865 // leaves this node with no peers — surface it loudly so a self-lockout (vs an attack)
866 // is diagnosable. `authority.is_some()` means a real keyed lock (the empty-keyset
867 // brick-guard admits-all, so it never reaches here with zero retained).
868 if authority.is_some() && !new_nodes.is_empty() && retained_ids.is_empty() {
869 tracing::error!(
870 offered = new_nodes.len(),
871 "TKA: active lock authorized ZERO of the offered peers; node is isolated \
872 (verify the lock state, or disable tailnet lock to recover)"
873 );
874 }
875
876 self.peer_db.retain(|id, peer| {
877 let retain = retained_ids.contains(&peer.stable_id);
878
879 if !retain {
880 deletions.insert(id);
881 }
882
883 retain
884 });
885
886 for (node, k) in new_nodes.iter().zip(keep.iter().copied()) {
887 if !k {
888 continue; // fail-CLOSED: rejected by tailnet lock or rotation-obsolete (above)
889 }
890 let peer_id = self.peer_db.upsert(node);
891 upserts.insert(peer_id);
892 }
893 }
894
895 ts_control::PeerUpdate::Delta { remove, upsert } => {
896 tracing::trace!("delta peer update");
897
898 for peer in upsert {
899 if !self.tka_admits(peer) {
900 // fail-CLOSED: do not upsert a peer rejected by tailnet lock. If the peer is
901 // ALREADY in the db (a delta re-upserting an existing peer whose signature is
902 // now invalid — e.g. revoked between syncs), evict the stale entry rather than
903 // leaving an unverified peer admitted; Go re-filters the whole netmap each map
904 // response, so a now-unsigned peer would not survive there either.
905 if let Some((id, _)) = self.peer_db.remove(&peer.stable_id) {
906 tracing::warn!(
907 stable_id = ?peer.stable_id,
908 "TKA: delta re-upsert rejected; evicting now-unauthorized peer"
909 );
910 deletions.insert(id);
911 }
912 continue;
913 }
914 let id = self.peer_db.upsert(peer);
915
916 upserts.insert(id);
917 }
918
919 for peer in remove {
920 let Some((id, _node)) = self.peer_db.remove(peer) else {
921 // A benign, expected race: the peer may already be gone (dropped in a prior
922 // `Full`, or fail-closed by TKA — whose now-"unknown" ids commonly reappear in
923 // a trailing `peers_removed`). Go treats an unknown removal as a no-op; log at
924 // debug, not error, to avoid false-alarm noise on a healthy node (matches the
925 // unknown-node handling in `apply_peer_patches`).
926 tracing::debug!(
927 control_node_id = peer,
928 "removed peer was unknown; ignoring"
929 );
930 continue;
931 };
932
933 deletions.insert(id);
934 }
935 }
936 }
937
938 (upserts, deletions)
939 }
940
941 /// Apply field-level peer patches (`MapResponse.PeersChangedPatch`), returning the upserted /
942 /// deleted [`PeerId`]s.
943 ///
944 /// This is a SEPARATE channel from [`apply_peer_update`](Self::apply_peer_update): Go's
945 /// `controlclient` applies the whole-node `Peers*` set first and then `PeersChangedPatch`, so a
946 /// response that carries both has the peer set applied first (by the caller) and these patches
947 /// applied second, on top of the freshly-synced nodes. A patch only mutates a peer already in the
948 /// netmap; an unknown node id is ignored (the wire contract — a patch never creates a node).
949 fn apply_peer_patches(
950 &mut self,
951 patches: &[ts_control::PeerChange],
952 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
953 let mut upserts = HashSet::default();
954 let mut deletions = HashSet::default();
955
956 tracing::trace!(n = patches.len(), "peer patch update");
957
958 for patch in patches {
959 // Clone the current node, apply the present fields, and re-upsert through the same path
960 // as a delta so indexes/routes stay consistent.
961 let Some((_id, existing)) = self.peer_db.get(&patch.id) else {
962 tracing::debug!(
963 control_node_id = patch.id,
964 "peer patch for unknown node; ignoring"
965 );
966 continue;
967 };
968
969 let mut node = existing.clone();
970 if let Some(endpoints) = &patch.underlay_addresses {
971 node.underlay_addresses = endpoints.clone();
972 }
973 if let Some(derp) = patch.derp_region {
974 node.derp_region = Some(derp);
975 }
976 if let Some(cap) = patch.cap {
977 node.cap = cap;
978 }
979 if let Some(cap_map) = &patch.cap_map {
980 node.cap_map = cap_map.clone();
981 }
982 if let Some(disco_key) = patch.disco_key {
983 node.disco_key = Some(disco_key);
984 }
985 if let Some(expiry) = patch.node_key_expiry {
986 node.node_key_expiry = Some(expiry);
987 }
988 // Online/last-seen liveness deltas (`PeerChange.Online`/`LastSeen`) — the dominant
989 // channel by which peer online transitions arrive mid-session. A patch only ever *sets*
990 // a value (never patches back to unknown), so apply when present.
991 if let Some(online) = patch.online {
992 node.online = Some(online);
993 }
994 if let Some(last_seen) = patch.last_seen {
995 node.last_seen = Some(last_seen);
996 }
997 // Key rotation: a patch may swap the node key (and its TKA signature). Apply both
998 // together so the trust gate below verifies the new signature against the new key, never
999 // a mismatched pair.
1000 if let Some(node_key) = patch.node_key {
1001 node.node_key = node_key;
1002 }
1003 if let Some(sig) = &patch.key_signature {
1004 node.key_signature = sig.clone();
1005 }
1006
1007 // Re-run the tailnet-lock gate on the patched node: a patch that rotates the key must
1008 // satisfy the active authority, exactly like a `Delta` upsert, or it would be a
1009 // trust-enforcement bypass. fail-CLOSED — if the patched node is no longer admitted,
1010 // evict it rather than keep the stale (now-unverified) entry.
1011 if !self.tka_admits(&node) {
1012 if let Some((id, _)) = self.peer_db.remove(&patch.id) {
1013 tracing::warn!(
1014 control_node_id = patch.id,
1015 "peer patch rejected by tailnet lock; evicting peer"
1016 );
1017 deletions.insert(id);
1018 }
1019 continue;
1020 }
1021
1022 let id = self.peer_db.upsert(&node);
1023 upserts.insert(id);
1024 }
1025
1026 (upserts, deletions)
1027 }
1028
1029 /// Apply the standalone online/last-seen delta maps (`MapResponse.OnlineChange` /
1030 /// `PeerSeenChange`, channels C/D) onto the retained netmap. Returns `true` if any node was
1031 /// actually mutated (so the caller knows whether to re-publish).
1032 ///
1033 /// Mirrors Go `controlclient/map.go:updatePeersStateFromResponse` (the two channels are
1034 /// semantically DISTINCT and must not be conflated):
1035 /// - `OnlineChange` (channel C) is the sole driver of a peer's `online` flag (`mut.Online = v`).
1036 /// - `PeerSeenChange` (channel D) is the sole driver of `last_seen`: `true ⇒ LastSeen = now`,
1037 /// `false ⇒ LastSeen = nil` (cleared). It NEVER touches `online` — "not seen recently" is not
1038 /// the same as "offline", which only `OnlineChange` asserts.
1039 ///
1040 /// Each entry is keyed by control node id and applies to a peer already in the netmap; an unknown
1041 /// node id is ignored (these maps never create a node). `now` is the wall-clock timestamp for a
1042 /// `PeerSeenChange: true` (Go uses `clock.Now()`); the caller passes it so this stays a pure
1043 /// function of its inputs. Returns `true` if any node was actually mutated.
1044 fn apply_liveness_changes(
1045 &mut self,
1046 online_change: &std::collections::BTreeMap<ts_control::NodeId, bool>,
1047 peer_seen_change: &std::collections::BTreeMap<ts_control::NodeId, bool>,
1048 now: chrono::DateTime<chrono::Utc>,
1049 ) -> bool {
1050 let mut changed = false;
1051
1052 // Channel C — direct online flips (the only writer of `online`).
1053 for (&node_id, &online) in online_change {
1054 if let Some((_pid, existing)) = self.peer_db.get(&node_id)
1055 && existing.online != Some(online)
1056 {
1057 let mut node = existing.clone();
1058 node.online = Some(online);
1059 self.peer_db.upsert(&node);
1060 changed = true;
1061 }
1062 }
1063
1064 // Channel D — peer-seen flips (the only writer of `last_seen`; never touches `online`).
1065 // `true` ⇒ last-seen is now; `false` ⇒ last-seen cleared (Go map.go:820-830).
1066 for (&node_id, &seen) in peer_seen_change {
1067 let new_last_seen = if seen { Some(now) } else { None };
1068 if let Some((_pid, existing)) = self.peer_db.get(&node_id)
1069 && existing.last_seen != new_last_seen
1070 {
1071 let mut node = existing.clone();
1072 node.last_seen = new_last_seen;
1073 self.peer_db.upsert(&node);
1074 changed = true;
1075 }
1076 }
1077
1078 changed
1079 }
1080
1081 /// Test-only constructor: build a [`PeerTracker`] with a chosen initial TKA authority without
1082 /// going through the actor `on_start` path. Returns the tracker plus the **`watch::Sender`** for
1083 /// its enforcement-authority cell, so a test can drive the exact enable/disable transitions the
1084 /// control runner drives at runtime (`tx.send_replace(Some(..))` ⇒ enforce, `tx.send_replace(None)`
1085 /// ⇒ clear). The initial `Some` exercises the fail-closed chokepoint
1086 /// ([`tka_admits`](Self::tka_admits)); `None` is the no-lock admit-all path. The returned sender
1087 /// must be kept alive for the tracker to read updated values.
1088 #[cfg(test)]
1089 fn for_test(
1090 env: Env,
1091 tka_authority: Option<ts_tka::Authority>,
1092 ) -> (Self, watch::Sender<Option<Arc<ts_tka::Authority>>>) {
1093 let (peer_watch, _) = watch::channel(Vec::new());
1094 let (tka_tx, tka_rx) = watch::channel(tka_authority.map(Arc::new));
1095 let tracker = Self {
1096 peer_db: PeerDb::default(),
1097 seen_state_update: false,
1098 pending_requests: Vec::new(),
1099 peer_watch,
1100 user_profiles: HashMap::new(),
1101 tka_authority: tka_rx,
1102 env,
1103 };
1104 (tracker, tka_tx)
1105 }
1106
1107 fn service_pending_requests(&mut self) {
1108 if self.seen_state_update {
1109 return;
1110 }
1111
1112 self.seen_state_update = true;
1113
1114 if !self.pending_requests.is_empty() {
1115 tracing::debug!(
1116 n_pending = self.pending_requests.len(),
1117 "state update received, servicing pending requests"
1118 );
1119 }
1120
1121 for req in core::mem::take(&mut self.pending_requests) {
1122 match req {
1123 Pending::PeerByName(PeerByName { name }, reply) => {
1124 reply.send(self.peer_by_name_opt(&name).cloned());
1125 }
1126 Pending::TailnetIp(PeerByTailnetIp { ip }, reply) => {
1127 reply.send(self.peer_by_tailnet_ip_opt(ip).cloned());
1128 }
1129 Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, reply) => {
1130 reply.send(
1131 self.peer_db
1132 .get_route(ip.into())
1133 .map(|(_id, node)| node.clone())
1134 .collect(),
1135 );
1136 }
1137 Pending::Status(reply) => {
1138 reply.send(self.status_peers_with_ids());
1139 }
1140 Pending::WhoIs(Whois { addr }, reply) => {
1141 reply.send(self.whois_opt(addr));
1142 }
1143 }
1144 }
1145 }
1146}
1147
1148#[cfg(test)]
1149mod tka_tests {
1150 //! Tailnet-Lock (TKA) enforcement tests for the peer-trust chokepoint.
1151 //!
1152 //! These exercise [`PeerTracker::tka_admits`] and the `tka_admits ⇒ upsert` loop the netmap
1153 //! handler runs. The test [`ts_tka::Authority`] is built with [`ts_tka::Authority::from_state`]
1154 //! over a known Ed25519 trusted key, and the signed node-key signature CBOR is produced through
1155 //! `ts_tka`'s public `cbor` encoder + `aum_hash` (the exact same canonical bytes `ts_tka`'s own
1156 //! `direct_signature_verifies_end_to_end` test signs, with no new crypto vectors invented and no
1157 //! private `ts_tka` API used).
1158
1159 use ed25519_dalek::{Signer, SigningKey};
1160 use ts_control::{Node, StableNodeId, TailnetAddress};
1161 use ts_tka::{
1162 AumHash, Authority, Key, KeyKind, State,
1163 cbor::{self, Value},
1164 };
1165
1166 use super::*;
1167
1168 /// `SigKind::Direct` wire value (Go `SigKind`; `ts_tka::SigKind::Direct = 1`).
1169 const SIG_KIND_DIRECT: u64 = 1;
1170
1171 /// The 32-byte node key used across the signed-peer fixtures.
1172 const NODE_KEY_BYTES: [u8; 32] = [7u8; 32];
1173
1174 /// Build a real [`Env`] for the tracker. Only the bus/keys/shutdown plumbing matters here; the
1175 /// TKA gate reads neither, so the forwarding preferences are all benign defaults.
1176 pub(super) fn test_env() -> Env {
1177 let (_shutdown_tx, shutdown_rx) = watch::channel(false);
1178 Env::new(
1179 ts_keys::NodeState::generate(),
1180 shutdown_rx,
1181 crate::env::ForwarderConfig {
1182 accept_routes: false,
1183 accept_dns: true,
1184 exit_node: None,
1185 forward_routes: Vec::new(),
1186 forward_tcp_ports: Vec::new(),
1187 forward_udp_ports: Vec::new(),
1188 forward_all_ports: false,
1189 forward_exit_egress: false,
1190 block_incoming: false,
1191 exit_proxy: None,
1192 peerapi_port: None,
1193 taildrop_dir: None,
1194 enable_ipv6: false,
1195 wireguard_listen_port: None,
1196 network_monitor: false,
1197 persistent_keepalive_interval: None,
1198 ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1199 },
1200 )
1201 }
1202
1203 /// A minimal peer [`Node`] carrying `node_key` and the given `key_signature`.
1204 pub(super) fn peer_node(stable_id: &str, node_key: [u8; 32], key_signature: Vec<u8>) -> Node {
1205 Node {
1206 id: 1,
1207 stable_id: StableNodeId(stable_id.to_string()),
1208 hostname: stable_id.to_string(),
1209 user_id: 0,
1210 tailnet: Some("ts.net".to_string()),
1211 tags: Vec::new(),
1212 tailnet_address: TailnetAddress {
1213 ipv4: "100.64.0.1/32".parse().unwrap(),
1214 ipv6: "fd7a:115c:a1e0::1/128".parse().unwrap(),
1215 },
1216 node_key: node_key.into(),
1217 node_key_expiry: None,
1218 online: None,
1219 last_seen: None,
1220 key_signature,
1221 machine_key: None,
1222 disco_key: None,
1223 accepted_routes: Vec::new(),
1224 underlay_addresses: Vec::new(),
1225 derp_region: None,
1226 cap: Default::default(),
1227 cap_map: Default::default(),
1228 peerapi_port: None,
1229 peerapi_dns_proxy: false,
1230 is_wireguard_only: false,
1231 exit_node_dns_resolvers: Vec::new(),
1232 peer_relay: false,
1233 ssh_host_keys: Vec::new(),
1234 service_vips: Default::default(),
1235 }
1236 }
1237
1238 /// Encode a `Direct` [`ts_tka::NodeKeySignature`] CBOR exactly as `ts_tka`'s private `to_cbor`
1239 /// does (int-map keys: 1=kind, 2=pubkey, 3=key_id, 4=signature; empty byte fields omitted),
1240 /// using only the crate's *public* `cbor` encoder. `signature` of `None` produces the
1241 /// signing-digest preimage (the `SigHash` form).
1242 fn direct_sig_cbor(node_key: &[u8], key_id: &[u8], signature: Option<&[u8]>) -> Vec<u8> {
1243 let mut pairs = alloc_pairs(node_key, key_id);
1244 if let Some(sig) = signature {
1245 pairs.push((4, Some(Value::Bytes(sig.to_vec()))));
1246 }
1247 cbor::int_map(pairs).to_vec()
1248 }
1249
1250 fn alloc_pairs(node_key: &[u8], key_id: &[u8]) -> Vec<(u64, Option<Value>)> {
1251 vec![
1252 (1, Some(Value::Uint(SIG_KIND_DIRECT))),
1253 (2, Some(Value::Bytes(node_key.to_vec()))),
1254 (3, Some(Value::Bytes(key_id.to_vec()))),
1255 ]
1256 }
1257
1258 /// Build a TKA [`Authority`] that trusts `signing.verifying_key()`, plus a valid `Direct`
1259 /// node-key signature CBOR authorizing [`NODE_KEY_BYTES`] under it.
1260 fn authority_and_valid_sig() -> (Authority, Vec<u8>) {
1261 // A fixed, known Ed25519 trusted key (mirrors ts_tka's own end-to-end test seed).
1262 let signing = SigningKey::from_bytes(&[42u8; 32]);
1263 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
1264
1265 let authority = Authority::from_state(
1266 AumHash([0; 32]),
1267 State {
1268 keys: vec![Key {
1269 kind: KeyKind::Ed25519,
1270 votes: 1,
1271 public: trusted_pub.clone(),
1272 }],
1273 },
1274 );
1275
1276 // SigHash preimage = canonical CBOR with the signature field omitted; sign its blake2s hash.
1277 let preimage = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, None);
1278 let sig_hash = ts_tka::aum_hash(&preimage).0;
1279 let signature = signing.sign(&sig_hash).to_bytes().to_vec();
1280
1281 let signed_cbor = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, Some(&signature));
1282 // Sanity: the authority accepts the signature we just built (same path the gate uses).
1283 assert!(
1284 authority
1285 .node_key_authorized(&NODE_KEY_BYTES, &signed_cbor)
1286 .is_ok()
1287 );
1288
1289 (authority, signed_cbor)
1290 }
1291
1292 #[tokio::test]
1293 async fn tka_inactive_upserts_all_peers() {
1294 // No authority ⇒ enforcement inactive ⇒ both a signed and an unsigned peer are admitted.
1295 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1296
1297 let signed = peer_node("signed", [1u8; 32], vec![0xde, 0xad, 0xbe, 0xef]);
1298 let unsigned = peer_node("unsigned", [2u8; 32], vec![]);
1299
1300 assert!(tracker.tka_admits(&signed));
1301 assert!(tracker.tka_admits(&unsigned));
1302
1303 tracker.peer_db.upsert(&signed);
1304 tracker.peer_db.upsert(&unsigned);
1305 assert_eq!(tracker.peer_db.peers().len(), 2);
1306 }
1307
1308 #[tokio::test]
1309 async fn tka_active_rejects_unsigned_peer() {
1310 // Authority present + peer presents no signature ⇒ rejected (fail-closed), not in peer_db.
1311 let (authority, _sig) = authority_and_valid_sig();
1312 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1313
1314 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
1315 assert!(!tracker.tka_admits(&unsigned));
1316
1317 // Mirror the handler's `if !tka_admits { continue }` loop.
1318 if tracker.tka_admits(&unsigned) {
1319 tracker.peer_db.upsert(&unsigned);
1320 }
1321 assert_eq!(tracker.peer_db.peers().len(), 0);
1322 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1323 }
1324
1325 #[tokio::test]
1326 async fn tka_active_rejects_bad_signature() {
1327 // Authority present + a signature that fails to verify ⇒ rejected, not in peer_db.
1328 let (authority, mut sig) = authority_and_valid_sig();
1329 // Tamper the last byte (the trailing signature byte) so verification fails.
1330 let last = sig.len() - 1;
1331 sig[last] ^= 0xff;
1332
1333 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1334 let bad = peer_node("bad", NODE_KEY_BYTES, sig);
1335 assert!(!tracker.tka_admits(&bad));
1336
1337 if tracker.tka_admits(&bad) {
1338 tracker.peer_db.upsert(&bad);
1339 }
1340 assert_eq!(tracker.peer_db.peers().len(), 0);
1341 }
1342
1343 #[tokio::test]
1344 async fn tka_active_admits_authorized_peer() {
1345 // Authority present + correctly-signed node key ⇒ admitted and upserted.
1346 let (authority, sig) = authority_and_valid_sig();
1347 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1348
1349 let good = peer_node("good", NODE_KEY_BYTES, sig);
1350 assert!(tracker.tka_admits(&good));
1351
1352 if tracker.tka_admits(&good) {
1353 tracker.peer_db.upsert(&good);
1354 }
1355 assert_eq!(tracker.peer_db.peers().len(), 1);
1356 assert!(tracker.peer_db.get(&good.node_key).is_some());
1357 }
1358
1359 // ---------------------------------------------------------------------------------------------
1360 // Tests that drive REAL `PeerUpdate`s through the shared handler body
1361 // ([`PeerTracker::apply_peer_update`], the single source of truth the actor's netmap `handle`
1362 // also calls), so the two real upsert sites (`Full` and `Delta { upsert }`) are exercised via
1363 // the actual enforcement path — not by hand-mirroring `if !tka_admits { continue }`.
1364 // ---------------------------------------------------------------------------------------------
1365
1366 #[tokio::test]
1367 async fn tka_active_delta_upsert_rejects_unauthorized() {
1368 // Drive a real `Delta { upsert }` whose peer carries no signature. The Delta upsert site
1369 // must reject it under an active authority ⇒ not present in peer_db after the handler runs.
1370 let (authority, _sig) = authority_and_valid_sig();
1371 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1372
1373 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
1374 let update = ts_control::PeerUpdate::Delta {
1375 upsert: vec![unsigned.clone()],
1376 remove: Vec::new(),
1377 };
1378
1379 tracker.apply_peer_update(&update);
1380
1381 assert_eq!(tracker.peer_db.peers().len(), 0);
1382 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1383 }
1384
1385 #[tokio::test]
1386 async fn tka_active_delta_upsert_admits_authorized() {
1387 // Drive a real `Delta { upsert }` with a correctly-signed peer ⇒ present in peer_db.
1388 let (authority, sig) = authority_and_valid_sig();
1389 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1390
1391 let good = peer_node("good", NODE_KEY_BYTES, sig);
1392 let update = ts_control::PeerUpdate::Delta {
1393 upsert: vec![good.clone()],
1394 remove: Vec::new(),
1395 };
1396
1397 tracker.apply_peer_update(&update);
1398
1399 assert_eq!(tracker.peer_db.peers().len(), 1);
1400 assert!(tracker.peer_db.get(&good.node_key).is_some());
1401 }
1402
1403 #[tokio::test]
1404 async fn tka_active_full_admits_only_authorized_in_mixed_batch() {
1405 // Drive a real `Full` carrying a MIX of authorized + unauthorized peers. Only the
1406 // correctly-signed peer survives the Full upsert site; the unsigned and bad-sig peers are
1407 // dropped fail-closed.
1408 let (authority, sig) = authority_and_valid_sig();
1409 // A bad-sig variant of the same authorized signature (tamper the trailing byte).
1410 let mut bad_sig = sig.clone();
1411 let last = bad_sig.len() - 1;
1412 bad_sig[last] ^= 0xff;
1413
1414 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1415
1416 // Only the authorized peer carries NODE_KEY_BYTES (the key the authority signed); the
1417 // rejected peers use distinct node keys so the survivor is unambiguous.
1418 let good = peer_node("good", NODE_KEY_BYTES, sig);
1419 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
1420 let bad = peer_node("bad", [9u8; 32], bad_sig);
1421
1422 let update =
1423 ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]);
1424
1425 tracker.apply_peer_update(&update);
1426
1427 assert_eq!(tracker.peer_db.peers().len(), 1);
1428 assert!(tracker.peer_db.get(&good.node_key).is_some());
1429 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1430 assert!(tracker.peer_db.get(&bad.node_key).is_none());
1431 }
1432
1433 /// End-to-end through the REAL enforcement-authority transport (the `watch` cell the control
1434 /// runner writes), not a direct field poke: writing `Some(authority)` flips enforcement on so a
1435 /// mixed batch drops the unsigned/bad peers, and a subsequent `None` (lock disabled) clears
1436 /// enforcement so a peer DROPPED while enforced is re-admitted. Exercises the exact `borrow`-based
1437 /// read path `tka_admits` uses — a broken receiver wiring would pass every for_test-field test but
1438 /// fail here.
1439 #[tokio::test]
1440 async fn tka_authority_watch_enables_then_clears_enforcement() {
1441 let (authority, sig) = authority_and_valid_sig();
1442 let mut bad_sig = sig.clone();
1443 let last = bad_sig.len() - 1;
1444 bad_sig[last] ^= 0xff;
1445
1446 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
1447
1448 // 1) No authority yet ⇒ admit-all (Go b.tka == nil).
1449 let good = peer_node("good", NODE_KEY_BYTES, sig.clone());
1450 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
1451 let bad = peer_node("bad", [9u8; 32], bad_sig);
1452 let batch = ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]);
1453 tracker.apply_peer_update(&batch);
1454 assert_eq!(tracker.peer_db.peers().len(), 3, "no lock ⇒ admit all");
1455
1456 // 2) Publish the verified authority over the watch cell (exactly what the control runner does
1457 // on a successful sync) ⇒ enforcement ON. A re-applied Full now drops unsigned + bad.
1458 tka_tx.send_replace(Some(Arc::new(authority)));
1459 tracker.apply_peer_update(&batch);
1460 assert_eq!(
1461 tracker.peer_db.peers().len(),
1462 1,
1463 "lock active ⇒ only the signed peer survives"
1464 );
1465 assert!(tracker.peer_db.get(&good.node_key).is_some());
1466 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1467 assert!(tracker.peer_db.get(&bad.node_key).is_none());
1468
1469 // 3) Lock disabled (None) ⇒ enforcement cleared ⇒ a peer that was DROPPED while enforced is
1470 // re-admitted by a fresh netmap. Assert the specific previously-dropped key returns (not
1471 // merely a count), so this proves the drop→clear→re-admit transition, not "admit-all-fresh".
1472 tka_tx.send_replace(None);
1473 tracker.apply_peer_update(&batch);
1474 assert_eq!(
1475 tracker.peer_db.peers().len(),
1476 3,
1477 "lock disabled ⇒ admit all again"
1478 );
1479 assert!(
1480 tracker.peer_db.get(&unsigned.node_key).is_some(),
1481 "the peer dropped under enforcement must come back once the lock is cleared"
1482 );
1483 assert!(tracker.peer_db.get(&bad.node_key).is_some());
1484 }
1485
1486 /// Degenerate input: two DISTINCT nodes sharing one `stable_id` in a single `Full`, one with a
1487 /// valid signature and one unsigned, under an active lock. Each node is judged by its OWN verdict
1488 /// (the per-node `admits` vector), so the unsigned node is never admitted on the strength of its
1489 /// signed twin. The single-verify `Full` refactor keeps this per-node semantics (a stable_id-set
1490 /// alone would have admitted whichever node was upserted last). Malformed control input; asserted
1491 /// only to lock the verdict-per-node behavior against regression.
1492 #[tokio::test]
1493 async fn tka_full_duplicate_stable_id_judges_each_node_on_its_own_signature() {
1494 let (authority, sig) = authority_and_valid_sig();
1495 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1496
1497 // Both carry stable_id "dup"; the signed one authorizes NODE_KEY_BYTES, the other is unsigned
1498 // and uses a different node key. Order them unsigned-last so a last-writer-wins stable_id set
1499 // would (wrongly) leave the unsigned node's key in the db.
1500 let signed = peer_node("dup", NODE_KEY_BYTES, sig);
1501 let unsigned = peer_node("dup", [8u8; 32], vec![]);
1502 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
1503 signed.clone(),
1504 unsigned.clone(),
1505 ]));
1506
1507 // The unsigned node's own verdict failed, so its key must NOT be present, regardless of the
1508 // shared stable_id. (The signed twin retained the stable_id; the db holds the signed key.)
1509 assert!(
1510 tracker.peer_db.get(&unsigned.node_key).is_none(),
1511 "a node whose own signature fails must not be admitted via a stable_id twin"
1512 );
1513 assert!(tracker.peer_db.get(&signed.node_key).is_some());
1514 }
1515
1516 /// Full-path consistency under two KEPT nodes sharing a `stable_id`: `peer_db.upsert` is
1517 /// last-writer-wins on `stable_id`, so the db ends holding exactly one node for that id (the last
1518 /// kept), and `retain` never evicts that just-upserted id (`retained_ids` contains the shared id
1519 /// because at least one of its nodes was kept). No lock here, so both nodes are "kept". This pins
1520 /// the published-state invariant the whole-surface audit flagged: `retain` and the upsert loop
1521 /// agree on the surviving stable_id. Malformed control input; asserted for robustness.
1522 #[tokio::test]
1523 async fn tka_full_duplicate_stable_id_both_kept_is_consistent() {
1524 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1525 let first = peer_node("dup", [1u8; 32], vec![]);
1526 let last = peer_node("dup", [2u8; 32], vec![]);
1527 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
1528 first.clone(),
1529 last.clone(),
1530 ]));
1531
1532 // Exactly one db entry for the shared stable_id, holding the LAST node (upsert is
1533 // last-writer-wins on stable_id); the first node's key was transparently superseded.
1534 assert_eq!(
1535 tracker.peer_db.peers().len(),
1536 1,
1537 "one entry for the shared stable_id"
1538 );
1539 assert!(
1540 tracker.peer_db.get(&last.node_key).is_some(),
1541 "the db holds the last-upserted node for the shared id"
1542 );
1543 assert!(
1544 tracker.peer_db.get(&first.node_key).is_none(),
1545 "the first node's key was superseded by the last at the shared id"
1546 );
1547 }
1548
1549 /// A peer admitted in one `Full`, then in a later `Full` presenting a key that a co-resident
1550 /// peer's rotation chain has rotated away, is EVICTED — the cross-peer rotation filter applies on
1551 /// every resync, not only at first admission. Exercises the rotation filter through two
1552 /// sequential `Full` updates with real signing.
1553 #[tokio::test]
1554 async fn tka_full_rotation_obsolete_evicts_on_resync() {
1555 use ed25519_dalek::SigningKey;
1556 use ts_tka::NodeKeySignature;
1557
1558 let trusted = SigningKey::from_bytes(&[42u8; 32]);
1559 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
1560 let authority = Authority::from_state(
1561 AumHash([0; 32]),
1562 State {
1563 keys: vec![Key {
1564 kind: KeyKind::Ed25519,
1565 votes: 1,
1566 public: trusted_pub.clone(),
1567 }],
1568 },
1569 );
1570 let pivot = SigningKey::from_bytes(&[9u8; 32]);
1571 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
1572
1573 // First Full: the soon-to-be-stale peer presents the pivot key with a valid Direct sig.
1574 let stale_sig = NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize();
1575 let stale_peer = peer_node("stale", pivot_pub, stale_sig);
1576 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1577 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![stale_peer.clone()]));
1578 assert!(
1579 tracker.peer_db.get(&stale_peer.node_key).is_some(),
1580 "the stale peer is admitted while no rotation has superseded it yet"
1581 );
1582
1583 // Second Full: a freshly-rotated peer (whose chain rotated AWAY the pivot key) joins, and the
1584 // stale peer is re-included. The rotation filter now obsoletes the pivot key ⇒ stale evicted.
1585 let new_key = [4u8; 32];
1586 let new_sig = NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize();
1587 let new_peer = peer_node("rotated", new_key, new_sig);
1588 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
1589 new_peer.clone(),
1590 stale_peer.clone(),
1591 ]));
1592 assert!(
1593 tracker.peer_db.get(&new_peer.node_key).is_some(),
1594 "the freshly-rotated peer is admitted"
1595 );
1596 assert!(
1597 tracker.peer_db.get(&stale_peer.node_key).is_none(),
1598 "the stale peer is EVICTED on the resync once a rotation supersedes its key"
1599 );
1600 }
1601
1602 /// The empty-trusted-key-state brick-guard: an authority with no keys must NOT drop the whole
1603 /// netmap (a `ts_tka` invariant violation / replayer edge). A verified chain always carries ≥1
1604 /// key, so this never weakens a genuine lock — it only prevents a black-hole. Uses ≥2 peers
1605 /// (one signed, one unsigned) to prove it admits **all**, not accidentally just one.
1606 #[tokio::test]
1607 async fn tka_empty_keyset_authority_admits_all() {
1608 use ts_tka::{AumHash, Authority, State};
1609 let empty_auth = Authority::from_state(AumHash([0u8; 32]), State { keys: Vec::new() });
1610 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(empty_auth));
1611 let signed = peer_node("signed", [7u8; 32], vec![0xde, 0xad]);
1612 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
1613 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
1614 signed.clone(),
1615 unsigned.clone(),
1616 ]));
1617 assert_eq!(
1618 tracker.peer_db.peers().len(),
1619 2,
1620 "an empty-keyset authority must admit ALL peers (brick-guard), not enforce"
1621 );
1622 }
1623
1624 /// Signature-replay / `NodeKeyMismatch`: a structurally-valid signature that authorizes
1625 /// `NODE_KEY_BYTES` must NOT admit a DIFFERENT node key carrying that same signature blob. This is
1626 /// the highest-value bypass — if the sig↔node-key binding in `verify_signature` were dropped, this
1627 /// is the only test that would catch it (the other "bad" peers only flip a byte ⇒ `BadSignature`).
1628 #[tokio::test]
1629 async fn tka_active_rejects_valid_sig_for_wrong_node_key() {
1630 let (authority, sig) = authority_and_valid_sig();
1631 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1632
1633 // The signature authorizes NODE_KEY_BYTES; attach it to an imposter with a different key.
1634 let imposter = peer_node("imposter", [0x55u8; 32], sig);
1635 assert!(
1636 !tracker.tka_admits(&imposter),
1637 "a signature bound to one node key must not authorize a different node key"
1638 );
1639 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![imposter.clone()]));
1640 assert!(tracker.peer_db.get(&imposter.node_key).is_none());
1641 }
1642
1643 /// `UntrustedKey`: a signature produced by a well-formed Ed25519 key that is NOT in the
1644 /// authority's trusted-key state must be rejected — distinct from a tampered-byte `BadSignature`.
1645 #[tokio::test]
1646 async fn tka_active_rejects_sig_from_untrusted_key() {
1647 use ed25519_dalek::{Signer, SigningKey};
1648 let (authority, _sig) = authority_and_valid_sig();
1649 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1650
1651 // Sign a valid CBOR with a DIFFERENT key (not the one the authority trusts). The key_id in
1652 // the signature names this untrusted key, so `get_key` misses ⇒ UntrustedKey.
1653 let rogue = SigningKey::from_bytes(&[99u8; 32]);
1654 let rogue_pub = rogue.verifying_key().to_bytes().to_vec();
1655 let preimage = direct_sig_cbor(&NODE_KEY_BYTES, &rogue_pub, None);
1656 let sig_hash = ts_tka::aum_hash(&preimage).0;
1657 let signature = rogue.sign(&sig_hash).to_bytes().to_vec();
1658 let rogue_cbor = direct_sig_cbor(&NODE_KEY_BYTES, &rogue_pub, Some(&signature));
1659
1660 let peer = peer_node("rogue-signed", NODE_KEY_BYTES, rogue_cbor);
1661 assert!(
1662 !tracker.tka_admits(&peer),
1663 "a signature from a key outside the trusted set must be rejected"
1664 );
1665 // Drive the real upsert path too (match the sibling replay test's depth): an untrusted-key
1666 // signature must keep the peer out of the db, not merely fail the verdict in isolation.
1667 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
1668 assert!(tracker.peer_db.get(&peer.node_key).is_none());
1669 }
1670
1671 /// Bus-enable analogue for `Delta`: enforcement engaged via the watch cell must also gate a
1672 /// `Delta { upsert }` (not only `Full`). Closes the "authority arrived over the transport AND the
1673 /// next update is a Delta" combination.
1674 #[tokio::test]
1675 async fn tka_watch_enable_enforces_delta_upsert() {
1676 let (authority, sig) = authority_and_valid_sig();
1677 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
1678 tka_tx.send_replace(Some(Arc::new(authority)));
1679
1680 let good = peer_node("good", NODE_KEY_BYTES, sig);
1681 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
1682 tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
1683 remove: vec![],
1684 upsert: vec![good.clone(), unsigned.clone()],
1685 });
1686 assert!(tracker.peer_db.get(&good.node_key).is_some());
1687 assert!(
1688 tracker.peer_db.get(&unsigned.node_key).is_none(),
1689 "delta upsert under an active lock must drop the unsigned peer"
1690 );
1691 }
1692
1693 /// A `Delta` re-upsert of an ALREADY-ADMITTED peer whose signature is now invalid must EVICT the
1694 /// stale entry (revocation-via-delta), not leave it admitted. Go re-filters the whole netmap each
1695 /// response, so a now-unsigned peer would not survive there either.
1696 #[tokio::test]
1697 async fn tka_delta_reupsert_with_invalid_sig_evicts_existing() {
1698 let (authority, sig) = authority_and_valid_sig();
1699 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1700
1701 // Admit the signed peer.
1702 let good = peer_node("good", NODE_KEY_BYTES, sig.clone());
1703 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
1704 assert!(tracker.peer_db.get(&good.node_key).is_some());
1705
1706 // Re-upsert the SAME stable_id (now with no signature) via a delta ⇒ evicted, not retained.
1707 let revoked = peer_node("good", NODE_KEY_BYTES, vec![]);
1708 tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
1709 remove: vec![],
1710 upsert: vec![revoked],
1711 });
1712 assert!(
1713 tracker.peer_db.get(&good.node_key).is_none(),
1714 "a delta re-upsert that fails the lock must evict the previously-admitted peer"
1715 );
1716 }
1717
1718 #[tokio::test]
1719 async fn tka_full_resync_revocation_behavior() {
1720 // Revocation-on-resync: admit a peer, then re-include the SAME stable_id in a `Full` with a
1721 // now-invalid signature. Per the Logic review finding, the pre-fix `retain` kept the stale
1722 // (previously-admitted) entry because membership was decided purely by stable_id.
1723 //
1724 // FIXED (not merely documented): the `Full` `retain` now keys on `tka_admits`-passing
1725 // stable_ids, so a peer whose re-included signature no longer verifies under the active
1726 // authority is EVICTED. This test asserts eviction. The inactive (authority=None) path is
1727 // provably unchanged — `tka_admits` always returns `true` there, so the retained set equals
1728 // the set of re-included stable_ids exactly (see `tka_inactive_full_resync_keeps_*`).
1729 let (authority, sig) = authority_and_valid_sig();
1730 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1731
1732 // 1) Admit the peer with a valid signature via a real `Full`.
1733 let good = peer_node("revoked", NODE_KEY_BYTES, sig.clone());
1734 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
1735 assert_eq!(tracker.peer_db.peers().len(), 1);
1736 assert!(tracker.peer_db.get(&good.node_key).is_some());
1737
1738 // 2) Re-sync the SAME stable_id, but with a now-invalid signature (tamper trailing byte).
1739 let mut bad_sig = sig;
1740 let last = bad_sig.len() - 1;
1741 bad_sig[last] ^= 0xff;
1742 let revoked = peer_node("revoked", NODE_KEY_BYTES, bad_sig);
1743 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![revoked.clone()]));
1744
1745 // Eviction: the stale entry is dropped because its re-included signature fails the gate.
1746 assert_eq!(tracker.peer_db.peers().len(), 0);
1747 assert!(tracker.peer_db.get(&revoked.node_key).is_none());
1748 }
1749
1750 #[tokio::test]
1751 async fn tka_inactive_full_resync_keeps_reincluded_peer() {
1752 // Guard the inactive (authority=None) path against the revocation fix: with no authority,
1753 // a peer re-included in a `Full` survives regardless of its signature bytes — byte-for-byte
1754 // pre-TKA behavior, proving the `Full` `retain` change does not regress the always-taken
1755 // branch this wave.
1756 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1757
1758 let peer = peer_node("p", NODE_KEY_BYTES, vec![0xde, 0xad]);
1759 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
1760 assert_eq!(tracker.peer_db.peers().len(), 1);
1761
1762 // Re-sync the same stable_id with garbage signature bytes; inactive enforcement keeps it.
1763 let resynced = peer_node("p", NODE_KEY_BYTES, vec![0x00]);
1764 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![resynced.clone()]));
1765 assert_eq!(tracker.peer_db.peers().len(), 1);
1766 assert!(tracker.peer_db.get(&resynced.node_key).is_some());
1767 }
1768
1769 /// A `Patch` for a peer already in the netmap merges only the fields it carries — here new UDP
1770 /// endpoints and a new home DERP — leaving the rest of the node intact. This is the fix for
1771 /// dropped `peers_changed_patch`: without it the netmap keeps stale endpoints and the peer can
1772 /// never re-handshake after it moves.
1773 #[tokio::test]
1774 async fn patch_merges_endpoints_and_derp_into_existing_peer() {
1775 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1776
1777 // Seed a peer (id == 1, per `peer_node`) with no endpoints / no DERP.
1778 let peer = peer_node("mover", [1u8; 32], vec![]);
1779 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
1780 let (_pid, before) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
1781 assert!(before.underlay_addresses.is_empty());
1782 assert!(before.derp_region.is_none());
1783
1784 // Patch in fresh reachability (the idle-peer-reconnect case).
1785 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
1786 let patch = ts_control::PeerChange {
1787 id: 1,
1788 derp_region: Some(ts_derp::RegionId(core::num::NonZeroU32::new(5).unwrap())),
1789 cap: None,
1790 cap_map: None,
1791 underlay_addresses: Some(vec![new_ep]),
1792 node_key: None,
1793 key_signature: None,
1794 disco_key: None,
1795 node_key_expiry: None,
1796 online: None,
1797 last_seen: None,
1798 };
1799 let (upserts, deletions) = tracker.apply_peer_patches(std::slice::from_ref(&patch));
1800
1801 assert_eq!(upserts.len(), 1);
1802 assert_eq!(deletions.len(), 0);
1803 // Same peer, now carrying the patched endpoint + DERP; node key untouched.
1804 assert_eq!(tracker.peer_db.peers().len(), 1);
1805 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
1806 assert_eq!(after.underlay_addresses, vec![new_ep]);
1807 assert_eq!(
1808 after.derp_region,
1809 Some(ts_derp::RegionId(core::num::NonZeroU32::new(5).unwrap()))
1810 );
1811 assert_eq!(after.node_key, peer.node_key);
1812 }
1813
1814 /// Regression for `tsr-5u0`: when a whole-node set (`Delta`/`Full`) and a patch co-occur in one
1815 /// response, the patch is applied *on top of* the node the set just upserted — mirroring the
1816 /// handler's apply-order (peer set first, then `peer_patches`). Before the fix the patch shared
1817 /// the single `peer_update` slot and the co-occurring set silently dropped it, so a peer brought
1818 /// in by the delta kept stale (empty) reachability.
1819 #[tokio::test]
1820 async fn patch_applies_on_top_of_co_occurring_delta() {
1821 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1822
1823 // The whole-node delta upserts a brand-new peer (id == 1) with no reachability.
1824 let peer = peer_node("mover", [1u8; 32], vec![]);
1825 let (set_upserts, _) = tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
1826 upsert: vec![peer.clone()],
1827 remove: vec![],
1828 });
1829 assert_eq!(set_upserts.len(), 1, "delta upserts the new peer");
1830
1831 // The patch from the SAME response then sets that peer's endpoints + DERP. This is exactly
1832 // the consumer order the handler runs (apply_peer_update then apply_peer_patches).
1833 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
1834 let patch = ts_control::PeerChange {
1835 id: 1,
1836 derp_region: Some(ts_derp::RegionId(core::num::NonZeroU32::new(7).unwrap())),
1837 cap: None,
1838 cap_map: None,
1839 underlay_addresses: Some(vec![new_ep]),
1840 node_key: None,
1841 key_signature: None,
1842 disco_key: None,
1843 node_key_expiry: None,
1844 online: None,
1845 last_seen: None,
1846 };
1847 let (patch_upserts, patch_deletions) =
1848 tracker.apply_peer_patches(std::slice::from_ref(&patch));
1849
1850 assert_eq!(
1851 patch_upserts.len(),
1852 1,
1853 "patch re-upserts the just-added peer"
1854 );
1855 assert_eq!(patch_deletions.len(), 0);
1856 // The peer added by the delta now carries the patched reachability — the patch was NOT lost.
1857 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
1858 assert_eq!(after.underlay_addresses, vec![new_ep]);
1859 assert_eq!(
1860 after.derp_region,
1861 Some(ts_derp::RegionId(core::num::NonZeroU32::new(7).unwrap()))
1862 );
1863 }
1864
1865 /// A `Patch` whose node id is not in the current netmap is ignored (the wire contract: a patch
1866 /// never creates a node). No upsert, no deletion, peer set unchanged.
1867 #[tokio::test]
1868 async fn patch_for_unknown_node_is_ignored() {
1869 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1870 let known = peer_node("known", [1u8; 32], vec![]); // id == 1
1871 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![known]));
1872
1873 let patch = ts_control::PeerChange {
1874 id: 999, // not in the netmap
1875 derp_region: None,
1876 cap: None,
1877 cap_map: None,
1878 underlay_addresses: Some(vec!["198.51.100.9:1".parse().unwrap()]),
1879 node_key: None,
1880 key_signature: None,
1881 disco_key: None,
1882 node_key_expiry: None,
1883 online: None,
1884 last_seen: None,
1885 };
1886 let (upserts, deletions) = tracker.apply_peer_patches(std::slice::from_ref(&patch));
1887
1888 assert_eq!(upserts.len(), 0);
1889 assert_eq!(deletions.len(), 0);
1890 assert_eq!(tracker.peer_db.peers().len(), 1);
1891 assert!(tracker.peer_db.get(&(999 as ts_control::NodeId)).is_none());
1892 }
1893
1894 /// An expiry-only `Patch` updates `node_key_expiry` on the matching peer (Go
1895 /// `PeerChange.KeyExpiry`), rather than being silently dropped until the next full resync.
1896 #[tokio::test]
1897 async fn patch_updates_node_key_expiry() {
1898 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1899 let peer = peer_node("expiring", [1u8; 32], vec![]); // id == 1, node_key_expiry: None
1900 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
1901
1902 let expiry = "2027-01-01T00:00:00Z"
1903 .parse::<chrono::DateTime<chrono::Utc>>()
1904 .unwrap();
1905 let patch = ts_control::PeerChange {
1906 id: 1,
1907 derp_region: None,
1908 cap: None,
1909 cap_map: None,
1910 underlay_addresses: None,
1911 node_key: None,
1912 key_signature: None,
1913 disco_key: None,
1914 node_key_expiry: Some(expiry),
1915 online: None,
1916 last_seen: None,
1917 };
1918 tracker.apply_peer_patches(std::slice::from_ref(&patch));
1919
1920 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
1921 assert_eq!(after.node_key_expiry, Some(expiry));
1922 }
1923
1924 /// Channel B: a `PeerChange.online` patch flips a peer's online state without a full node.
1925 #[tokio::test]
1926 async fn patch_updates_online() {
1927 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1928 let peer = peer_node("p", [1u8; 32], vec![]); // id == 1, online: None
1929 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
1930 assert_eq!(
1931 tracker
1932 .peer_db
1933 .get(&(1 as ts_control::NodeId))
1934 .unwrap()
1935 .1
1936 .online,
1937 None
1938 );
1939
1940 let mut patch = ts_control::PeerChange {
1941 id: 1,
1942 derp_region: None,
1943 cap: None,
1944 cap_map: None,
1945 underlay_addresses: None,
1946 node_key: None,
1947 key_signature: None,
1948 disco_key: None,
1949 node_key_expiry: None,
1950 online: Some(true),
1951 last_seen: None,
1952 };
1953 tracker.apply_peer_patches(std::slice::from_ref(&patch));
1954 assert_eq!(
1955 tracker
1956 .peer_db
1957 .get(&(1 as ts_control::NodeId))
1958 .unwrap()
1959 .1
1960 .online,
1961 Some(true),
1962 "PeerChange.online=Some(true) marks the peer online"
1963 );
1964
1965 // A subsequent patch flips it offline.
1966 patch.online = Some(false);
1967 tracker.apply_peer_patches(std::slice::from_ref(&patch));
1968 assert_eq!(
1969 tracker
1970 .peer_db
1971 .get(&(1 as ts_control::NodeId))
1972 .unwrap()
1973 .1
1974 .online,
1975 Some(false)
1976 );
1977 }
1978
1979 /// Channel C/D (Go `map.go:updatePeersStateFromResponse`): `online_change` is the sole driver of
1980 /// `online`; `peer_seen_change` is the sole driver of `last_seen` (true ⇒ now, false ⇒ cleared)
1981 /// and must NEVER touch `online`. Both apply to a peer already in the netmap and ignore unknown
1982 /// ids. This pins the fix for the prior bug where channel D wrote `online=false` (conflating
1983 /// "not seen recently" with "offline" — distinct signals in Go).
1984 #[tokio::test]
1985 async fn liveness_change_maps_apply_online() {
1986 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1987 let peer = peer_node("p", [1u8; 32], vec![]); // id == 1
1988 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
1989 // A fixed timestamp (chrono is built without its `clock` feature, so no `Utc::now()`).
1990 let now = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
1991
1992 // Channel C: online_change sets online=true.
1993 let mut online_change = std::collections::BTreeMap::new();
1994 online_change.insert(1 as ts_control::NodeId, true);
1995 online_change.insert(999 as ts_control::NodeId, true); // unknown id — ignored
1996 let changed = tracker.apply_liveness_changes(&online_change, &Default::default(), now);
1997 assert!(changed);
1998 assert_eq!(
1999 tracker
2000 .peer_db
2001 .get(&(1 as ts_control::NodeId))
2002 .unwrap()
2003 .1
2004 .online,
2005 Some(true)
2006 );
2007
2008 // Channel D: peer_seen_change=true sets last_seen=now and leaves online UNTOUCHED.
2009 let mut seen_true = std::collections::BTreeMap::new();
2010 seen_true.insert(1 as ts_control::NodeId, true);
2011 let changed = tracker.apply_liveness_changes(&Default::default(), &seen_true, now);
2012 assert!(changed);
2013 {
2014 let (_id, node) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2015 assert_eq!(
2016 node.last_seen,
2017 Some(now),
2018 "peer_seen_change=true sets last_seen=now"
2019 );
2020 assert_eq!(
2021 node.online,
2022 Some(true),
2023 "channel D must NOT touch online (still true from channel C)"
2024 );
2025 }
2026
2027 // Channel D: peer_seen_change=false clears last_seen, still leaving online untouched.
2028 let mut seen_false = std::collections::BTreeMap::new();
2029 seen_false.insert(1 as ts_control::NodeId, false);
2030 let changed = tracker.apply_liveness_changes(&Default::default(), &seen_false, now);
2031 assert!(changed);
2032 {
2033 let (_id, node) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2034 assert_eq!(
2035 node.last_seen, None,
2036 "peer_seen_change=false clears last_seen"
2037 );
2038 assert_eq!(node.online, Some(true), "channel D must NOT mark offline");
2039 }
2040 assert_eq!(
2041 tracker.peer_db.peers().len(),
2042 1,
2043 "the node is retained, not removed"
2044 );
2045
2046 // No-op when nothing matches / changes.
2047 assert!(!tracker.apply_liveness_changes(&Default::default(), &Default::default(), now));
2048 }
2049
2050 /// Security: a `Patch` that rotates the node key must re-satisfy the tailnet-lock authority,
2051 /// exactly like a `Delta` upsert. A key-rotation patch whose new signature does NOT verify
2052 /// evicts the peer (fail-closed) rather than leaving a now-unverified entry — closing what would
2053 /// otherwise be a trust-enforcement bypass via the patch path.
2054 #[tokio::test]
2055 async fn patch_key_rotation_failing_tka_evicts_peer() {
2056 let (authority, sig) = authority_and_valid_sig();
2057 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2058
2059 // Admit a correctly-signed peer (id == 1).
2060 let good = peer_node("rotator", NODE_KEY_BYTES, sig.clone());
2061 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
2062 assert_eq!(tracker.peer_db.peers().len(), 1);
2063
2064 // Patch a new node key whose signature is garbage under the active authority.
2065 let patch = ts_control::PeerChange {
2066 id: 1,
2067 derp_region: None,
2068 cap: None,
2069 cap_map: None,
2070 underlay_addresses: None,
2071 node_key: Some([0x33u8; 32].into()),
2072 key_signature: Some(vec![0x00, 0x01, 0x02]),
2073 disco_key: None,
2074 node_key_expiry: None,
2075 online: None,
2076 last_seen: None,
2077 };
2078 let (upserts, deletions) = tracker.apply_peer_patches(std::slice::from_ref(&patch));
2079
2080 assert_eq!(upserts.len(), 0);
2081 assert_eq!(deletions.len(), 1);
2082 assert_eq!(tracker.peer_db.peers().len(), 0);
2083 }
2084
2085 /// A node's `user_id` joins against the accumulated UserProfiles table to resolve the owning
2086 /// user's login name in `WhoIs.user`. With no matching profile, `user` is `None` (the
2087 /// pre-existing behavior); once a profile arrives, the same node resolves to its login. This
2088 /// proves the accumulate-then-join path the netmap handler builds.
2089 fn profile(id: ts_control::UserId, login: &str) -> ts_control::UserProfile {
2090 ts_control::UserProfile {
2091 id,
2092 login_name: login.to_string(),
2093 display_name: None,
2094 }
2095 }
2096
2097 #[tokio::test]
2098 async fn whois_resolves_user_from_accumulated_profiles() {
2099 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2100
2101 // A peer owned by user id 42 at 100.64.0.1 (the peer_node fixture's address).
2102 let mut peer = peer_node("p", NODE_KEY_BYTES, Vec::new());
2103 peer.user_id = 42;
2104 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
2105 let addr = "100.64.0.1:0".parse().unwrap();
2106
2107 // No profile yet: the node resolves but its owner is unknown.
2108 let who = tracker.whois_opt(addr).expect("peer is known");
2109 assert_eq!(who.user, None);
2110
2111 // Profile for a DIFFERENT user must not match.
2112 tracker
2113 .user_profiles
2114 .insert(7, profile(7, "someone-else@example.com"));
2115 assert_eq!(tracker.whois_opt(addr).unwrap().user, None);
2116
2117 // The owning user's profile arrives (as the netmap handler would accumulate it): now the
2118 // login resolves.
2119 tracker
2120 .user_profiles
2121 .insert(42, profile(42, "alice@example.com"));
2122 assert_eq!(
2123 tracker.whois_opt(addr).unwrap().user,
2124 Some("alice@example.com".to_string())
2125 );
2126 }
2127
2128 /// `UserProfile::best_label` prefers the login name, falling back to display name, else `None`.
2129 #[test]
2130 fn user_profile_best_label_prefers_login() {
2131 assert_eq!(
2132 profile(1, "alice@example.com").best_label(),
2133 Some("alice@example.com".to_string())
2134 );
2135 let display_only = ts_control::UserProfile {
2136 id: 2,
2137 login_name: String::new(),
2138 display_name: Some("Bob".to_string()),
2139 };
2140 assert_eq!(display_only.best_label(), Some("Bob".to_string()));
2141 let empty = ts_control::UserProfile {
2142 id: 3,
2143 login_name: String::new(),
2144 display_name: None,
2145 };
2146 assert_eq!(empty.best_label(), None);
2147 }
2148
2149 // ----- tsr-jo1: RotationTracker (Go ipnlocal.rotationTracker.obsoleteKeys) -----
2150
2151 /// A `RotationDetails` for a `Direct`-rooted chain with the given prior keys + wrapping key.
2152 fn rot_details(
2153 prev: &[&[u8]],
2154 wrapping: &[u8],
2155 kind: ts_tka::SigKind,
2156 ) -> ts_tka::RotationDetails {
2157 ts_tka::RotationDetails {
2158 prev_node_keys: prev.iter().map(|p| p.to_vec()).collect(),
2159 initial_sig_kind: kind,
2160 initial_wrapping_pubkey: wrapping.to_vec(),
2161 }
2162 }
2163
2164 /// Rule 1: every prior node key named by any rotation chain is obsolete, regardless of the
2165 /// chain's root kind (Go's ungated `obsolete.AddSlice(d.PrevNodeKeys)`).
2166 #[test]
2167 fn rotation_tracker_prev_keys_always_obsolete() {
2168 let mut t = RotationTracker::default();
2169 // A Direct-rooted chain that rotated away OLD1, and a Credential-rooted one that rotated OLD2.
2170 t.add(
2171 b"newA".to_vec(),
2172 &rot_details(&[b"OLD1"], b"wrapA", ts_tka::SigKind::Direct),
2173 );
2174 t.add(
2175 b"newB".to_vec(),
2176 &rot_details(&[b"OLD2"], b"wrapB", ts_tka::SigKind::Credential),
2177 );
2178 let obsolete = t.obsolete_keys();
2179 assert!(
2180 obsolete.contains(b"OLD1".as_slice()),
2181 "Direct chain's prior key obsolete"
2182 );
2183 assert!(
2184 obsolete.contains(b"OLD2".as_slice()),
2185 "Credential chain's prior key obsolete too (rule 1 is ungated)"
2186 );
2187 // The current keys themselves are not obsolete (only one peer per wrapping key here).
2188 assert!(!obsolete.contains(b"newA".as_slice()));
2189 assert!(!obsolete.contains(b"newB".as_slice()));
2190 }
2191
2192 /// Rule 2: among `Direct`-rooted chains sharing a wrapping key, only the longest survives; the
2193 /// shorter (older) clone's key is obsolete.
2194 #[test]
2195 fn rotation_tracker_unequal_chain_keeps_longest() {
2196 let mut t = RotationTracker::default();
2197 // Same wrapping key; "long" has 2 prior keys, "short" has 1 ⇒ "short" is the older clone.
2198 t.add(
2199 b"long".to_vec(),
2200 &rot_details(&[b"p1", b"p2"], b"wrap", ts_tka::SigKind::Direct),
2201 );
2202 t.add(
2203 b"short".to_vec(),
2204 &rot_details(&[b"q1"], b"wrap", ts_tka::SigKind::Direct),
2205 );
2206 let obsolete = t.obsolete_keys();
2207 assert!(
2208 obsolete.contains(b"short".as_slice()),
2209 "the shorter-chain clone is obsolete"
2210 );
2211 assert!(
2212 !obsolete.contains(b"long".as_slice()),
2213 "the longest-chain peer survives"
2214 );
2215 }
2216
2217 /// Rule 2 tie: two `Direct`-rooted chains sharing a wrapping key with EQUAL chain length cannot
2218 /// be disambiguated ⇒ BOTH are dropped (Go's safety branch).
2219 #[test]
2220 fn rotation_tracker_equal_chain_drops_both() {
2221 let mut t = RotationTracker::default();
2222 t.add(
2223 b"cloneA".to_vec(),
2224 &rot_details(&[b"p1"], b"wrap", ts_tka::SigKind::Direct),
2225 );
2226 t.add(
2227 b"cloneB".to_vec(),
2228 &rot_details(&[b"p2"], b"wrap", ts_tka::SigKind::Direct),
2229 );
2230 let obsolete = t.obsolete_keys();
2231 assert!(
2232 obsolete.contains(b"cloneA".as_slice()),
2233 "tied clone A dropped"
2234 );
2235 assert!(
2236 obsolete.contains(b"cloneB".as_slice()),
2237 "tied clone B dropped"
2238 );
2239 }
2240
2241 /// `Credential`-rooted chains sharing a wrapping key are EXEMPT from rule 2 (reusable-authkey
2242 /// carve-out): both are kept even with equal chain length.
2243 #[test]
2244 fn rotation_tracker_credential_root_clones_both_kept() {
2245 let mut t = RotationTracker::default();
2246 t.add(
2247 b"credA".to_vec(),
2248 &rot_details(&[b"p1"], b"wrap", ts_tka::SigKind::Credential),
2249 );
2250 t.add(
2251 b"credB".to_vec(),
2252 &rot_details(&[b"p2"], b"wrap", ts_tka::SigKind::Credential),
2253 );
2254 let obsolete = t.obsolete_keys();
2255 assert!(
2256 !obsolete.contains(b"credA".as_slice()),
2257 "credential-rooted clone A kept"
2258 );
2259 assert!(
2260 !obsolete.contains(b"credB".as_slice()),
2261 "credential-rooted clone B kept"
2262 );
2263 }
2264
2265 /// A peer that another chain already rotated away does not also act as a surviving clone: it is
2266 /// removed from its wrapping-key group before the longest-survivor pick (Go's `DeleteFunc`).
2267 #[test]
2268 fn rotation_tracker_already_obsolete_peer_not_a_survivor() {
2269 let mut t = RotationTracker::default();
2270 // "victim" is rotated away by "rotator" (different wrapping key), AND shares wrapping key
2271 // "w" with "other". Because "victim" is already obsolete, only "other" is in play for "w" and
2272 // survives (no spurious tie-drop of "other").
2273 t.add(
2274 b"rotator".to_vec(),
2275 &rot_details(&[b"victim"], b"wRot", ts_tka::SigKind::Direct),
2276 );
2277 t.add(
2278 b"victim".to_vec(),
2279 &rot_details(&[b"x"], b"w", ts_tka::SigKind::Direct),
2280 );
2281 t.add(
2282 b"other".to_vec(),
2283 &rot_details(&[b"y"], b"w", ts_tka::SigKind::Direct),
2284 );
2285 let obsolete = t.obsolete_keys();
2286 assert!(
2287 obsolete.contains(b"victim".as_slice()),
2288 "victim rotated away by rotator"
2289 );
2290 assert!(
2291 !obsolete.contains(b"other".as_slice()),
2292 "other survives — victim was removed from the group before the tie check"
2293 );
2294 }
2295
2296 /// Empty tracker (no rotation-signed peers) ⇒ no obsolete keys (the non-rotation netmap path).
2297 #[test]
2298 fn rotation_tracker_empty_is_noop() {
2299 let t = RotationTracker::default();
2300 assert!(t.obsolete_keys().is_empty());
2301 }
2302
2303 /// End-to-end through the real `Full` path: a peer presenting a freshly-rotated key (a Rotation
2304 /// chain) is admitted, while a second peer still presenting the rotated-AWAY pivot key — even with
2305 /// that key's own still-valid Direct signature — is DROPPED by the cross-peer rotation filter.
2306 /// This is the gap closed here: Go `tkaFilterNetmapLocked` drops the stale clone; we used to admit
2307 /// it. Uses real `ts_tka` signing (`sign_direct` + `sign_rotation`) so the whole
2308 /// verify → details → filter pipeline runs.
2309 ///
2310 /// Construction: the trusted key signs an inner `Direct` over the PIVOT keypair's public key; the
2311 /// pivot key then signs an outer `Rotation` authorizing `new_key`. That chain's `prev_node_keys`
2312 /// names the pivot pubkey — so a peer presenting the pivot pubkey as its node key is the
2313 /// rotated-away key the filter must drop.
2314 #[tokio::test]
2315 async fn tka_full_drops_rotated_away_key_e2e() {
2316 use ed25519_dalek::SigningKey;
2317 use ts_tka::NodeKeySignature;
2318
2319 let trusted = SigningKey::from_bytes(&[42u8; 32]);
2320 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2321 let authority = Authority::from_state(
2322 AumHash([0; 32]),
2323 State {
2324 keys: vec![Key {
2325 kind: KeyKind::Ed25519,
2326 votes: 1,
2327 public: trusted_pub.clone(),
2328 }],
2329 },
2330 );
2331
2332 // The rotation pivot: a keypair whose public key the inner Direct authorizes and whose
2333 // private key signs the outer rotation wrap. This pivot pubkey IS the key being rotated away.
2334 let pivot = SigningKey::from_bytes(&[9u8; 32]);
2335 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
2336
2337 let new_key = [4u8; 32]; // the freshly-rotated node key
2338
2339 // Fresh peer: a Rotation chain authorizing `new_key`, inner Direct over the pivot signed by
2340 // trusted, outer wrap signed by the pivot. Its prev_node_keys names `pivot_pub`.
2341 let new_sig = NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize();
2342 let new_peer = peer_node("rotated", new_key, new_sig);
2343
2344 // Stale peer: still presents the pivot pubkey (the rotated-away key) with its own valid
2345 // Direct signature — valid in isolation, but obsoleted by the fresh peer's rotation chain.
2346 let stale_sig = NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize();
2347 let stale_peer = peer_node("stale", pivot_pub, stale_sig);
2348
2349 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2350 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2351 new_peer.clone(),
2352 stale_peer.clone(),
2353 ]));
2354
2355 assert!(
2356 tracker.peer_db.get(&new_peer.node_key).is_some(),
2357 "the freshly-rotated peer is admitted"
2358 );
2359 assert!(
2360 tracker.peer_db.get(&stale_peer.node_key).is_none(),
2361 "the peer presenting the rotated-away key is dropped (Go tkaFilterNetmapLocked)"
2362 );
2363 }
2364}
2365
2366#[cfg(test)]
2367mod tsmp_disco_key_tests {
2368 //! Receive side of the TSMP disco-key advertisement, at the point the key is *learned*.
2369 //!
2370 //! These exercise [`PeerTracker::learn_disco_key`] — the fork's stand-in for Go
2371 //! `magicsock.Conn.HandleDiscoKeyAdvertisement` — which is the single place an advertisement
2372 //! reaches peer state. The wire decode and the "consumed, not delivered" drop are covered in
2373 //! `ts_packet::tsmp` and `ts_dataplane` respectively.
2374
2375 use ts_keys::DiscoPublicKey;
2376
2377 use super::{
2378 tka_tests::{peer_node, test_env},
2379 *,
2380 };
2381
2382 /// The key a peer advertises, and a second one for the re-advertise case.
2383 const ADVERTISED: [u8; 32] = [0xa5u8; 32];
2384 const READVERTISED: [u8; 32] = [0x5au8; 32];
2385
2386 /// A tracker holding one peer with no disco key yet, plus that peer's [`PeerId`].
2387 fn tracker_with_peer() -> (PeerTracker, PeerId) {
2388 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2389 let node = peer_node("peer", [1u8; 32], Vec::new());
2390 let id = tracker.peer_db.upsert(&node);
2391 (tracker, id)
2392 }
2393
2394 /// The happy path: an advertised key is applied to the peer AND lands in the disco index, which
2395 /// is what the direct-path machinery (`direct::DiscoPeerLookup`) reads. Re-advertising the same
2396 /// key is a no-op; advertising a different one replaces it, retracting the old index entry.
2397 #[tokio::test]
2398 async fn advertisement_learns_the_peers_disco_key() {
2399 let (mut tracker, peer) = tracker_with_peer();
2400 let key = DiscoPublicKey::from(ADVERTISED);
2401
2402 assert!(
2403 tracker.learn_disco_key(peer, key),
2404 "a first advertisement changes the peer db"
2405 );
2406 assert_eq!(
2407 tracker
2408 .peer_db
2409 .get(&peer)
2410 .expect("peer still present")
2411 .1
2412 .disco_key,
2413 Some(key),
2414 "the advertised disco key is learned"
2415 );
2416 assert_eq!(
2417 tracker.peer_db.has(&key),
2418 Some(peer),
2419 "and is reachable through the disco index the direct path resolves against"
2420 );
2421
2422 assert!(
2423 !tracker.learn_disco_key(peer, key),
2424 "re-advertising the same key is a no-op (Go counts it 'unchanged' and returns)"
2425 );
2426
2427 let rotated = DiscoPublicKey::from(READVERTISED);
2428 assert!(tracker.learn_disco_key(peer, rotated));
2429 assert_eq!(
2430 tracker
2431 .peer_db
2432 .get(&peer)
2433 .expect("peer still present")
2434 .1
2435 .disco_key,
2436 Some(rotated),
2437 "a later advertisement replaces the key without a netmap update"
2438 );
2439 assert_eq!(tracker.peer_db.has(&rotated), Some(peer));
2440 assert_eq!(
2441 tracker.peer_db.has(&key),
2442 None,
2443 "the superseded key no longer resolves to the peer"
2444 );
2445 }
2446
2447 /// The refusals, each of which must leave the peer db untouched: the zero key is never learned,
2448 /// and an advertisement never creates a peer.
2449 #[tokio::test]
2450 async fn refused_advertisements_change_nothing() {
2451 let (mut tracker, peer) = tracker_with_peer();
2452
2453 assert!(
2454 !tracker.learn_disco_key(peer, DiscoPublicKey::from([0u8; 32])),
2455 "the zero key is never learned"
2456 );
2457 assert_eq!(
2458 tracker
2459 .peer_db
2460 .get(&peer)
2461 .expect("peer still present")
2462 .1
2463 .disco_key,
2464 None,
2465 "a zero-key advertisement must not bind the peer to an unusable key"
2466 );
2467
2468 // An advertisement for a peer control has never told us about. Go logs "endpoint not found
2469 // for node" and returns; it must not conjure a peer into existence.
2470 let unknown = PeerId(4242);
2471 assert_eq!(tracker.peer_db.get(&unknown), None, "precondition");
2472 assert!(
2473 !tracker.learn_disco_key(unknown, DiscoPublicKey::from(ADVERTISED)),
2474 "an advertisement for an unknown peer is ignored"
2475 );
2476 assert_eq!(
2477 tracker.peer_db.peers().len(),
2478 1,
2479 "an advertisement never creates a peer — only control does"
2480 );
2481 assert_eq!(
2482 tracker.peer_db.has(&DiscoPublicKey::from(ADVERTISED)),
2483 None,
2484 "and never indexes a key against a peer that does not exist"
2485 );
2486 }
2487}