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::{ExpiryManager, Node, UserId, UserProfile};
16use ts_keys::{DiscoPublicKey, NodePublicKey};
17use ts_transport::PeerId;
18
19use crate::{
20 Error, dataplane::PeerDiscoKeyAdvertisement, direct::DiscoKeyObserved, env::Env,
21 status::StatusNode,
22};
23
24mod peer_db;
25
26pub use peer_db::{DiscoKeyMatch, PeerDb};
27
28/// Whether `key` is the all-zero disco key, which Go spells `key.DiscoPublic.IsZero()` and treats
29/// everywhere as "this peer has no disco key" rather than as a usable key.
30fn disco_key_is_zero(key: &DiscoPublicKey) -> bool {
31 key.to_bytes() == [0u8; DiscoPublicKey::KEY_LEN_BYTES]
32}
33
34/// Normalize a disco key as it arrives from control: the all-zero key means "absent", exactly as
35/// Go's `IsZero()` checks in `endpoint.updateDiscoKey` read it.
36fn disco_key_from_control(key: Option<DiscoPublicKey>) -> Option<DiscoPublicKey> {
37 key.filter(|k| !disco_key_is_zero(k))
38}
39
40/// The local wall clock as a UTC timestamp.
41///
42/// chrono is built without its `clock` feature in this workspace, so derive it from `SystemTime`
43/// the same way the control runner and the ssh-policy paths do. A clock before the Unix epoch
44/// (unrepresentable) falls back to the epoch itself, which the expiry pass then refuses as being
45/// before its hardcoded epoch — fail-safe: flag nothing rather than expire everything.
46fn local_now() -> chrono::DateTime<chrono::Utc> {
47 std::time::SystemTime::now()
48 .duration_since(std::time::UNIX_EPOCH)
49 .ok()
50 .and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
51 .unwrap_or_default()
52}
53
54/// The two disco keys a peer can present, and which of them is currently active — Go
55/// [`magicsock.endpointDisco`] (`wgengine/magicsock/endpoint.go`).
56///
57/// A peer's disco key reaches us from two independent sources: **control**, in a netmap node or a
58/// `PeersChangedPatch`, and the **peer itself**, in a TSMP disco-key advertisement carried inside
59/// the WireGuard tunnel. Go keeps both side by side on the endpoint, and so do we, because control
60/// is the slower of the two: an advertisement exists precisely to cover the window where control has
61/// not caught up with the peer's current key, so collapsing the two into one field would let the
62/// next map poll overwrite a freshly-learned key with control's stale one — losing the feature's own
63/// motivating case.
64///
65/// Only one key is active for sending at a time ([`key`](Self::key)). That active key is what the
66/// peer db carries in [`Node::disco_key`], which is this fork's live lookup for every direct-path
67/// consumer (`direct::DiscoPeerLookup` resolves against it, and `PeerDb`'s disco index is built from
68/// it) — the stand-in for Go's per-endpoint `disco` pointer.
69///
70/// [`magicsock.endpointDisco`]: https://github.com/tailscale/tailscale/blob/49e148c4a30b4f8098f69468fd27a7021d85ea02/wgengine/magicsock/endpoint.go
71#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
72struct EndpointDisco {
73 /// The key learned from control (Go `endpointDisco.controlKey`).
74 control: Option<DiscoPublicKey>,
75 /// The key learned from a TSMP advertisement (Go `endpointDisco.tsmpKey`).
76 tsmp: Option<DiscoPublicKey>,
77 /// Whether [`tsmp`](Self::tsmp) is the active key (Go `endpointDisco.tsmpActive`).
78 tsmp_active: bool,
79}
80
81impl EndpointDisco {
82 /// The key currently regarded as active — Go `endpointDisco.key()`.
83 fn key(&self) -> Option<DiscoPublicKey> {
84 if self.tsmp_active {
85 self.tsmp
86 } else {
87 self.control
88 }
89 }
90
91 /// The control-learned key, active or not — Go `endpointDisco.keyFromControl()`.
92 fn key_from_control(&self) -> Option<DiscoPublicKey> {
93 self.control
94 }
95
96 /// The TSMP-learned key, active or not — Go `endpointDisco.keyFromTSMP()`.
97 fn key_from_tsmp(&self) -> Option<DiscoPublicKey> {
98 self.tsmp
99 }
100
101 /// Replace the control-learned key, leaving any TSMP-learned key in place — Go
102 /// [`endpoint.updateDiscoKey`].
103 ///
104 /// Control's key is always recorded in control's own slot, but it takes the *active* slot only
105 /// if no TSMP-learned key already holds it: Go `epDisco.tsmpActive = old.tsmpActive ||
106 /// key.IsZero()`. A key the peer told us itself is better evidence than a control server that
107 /// is, by construction, the slower of the two sources — so control changing its mind no longer
108 /// preempts an active TSMP key. Upstream returns to control's key when disco is actually
109 /// *received* under it (`endpoint.checkAndUpdateDiscoKey`), not when control asserts it.
110 ///
111 /// An absent (Go: zero) control key still hands the slot to the TSMP key, if there is one. When
112 /// there is neither key, the caller drops the whole entry ([`is_empty`](Self::is_empty)) — which
113 /// is what stops an active TSMP slot with no TSMP key in it outliving this call, exactly as Go
114 /// nils the endpoint's `disco` pointer in the same case.
115 ///
116 /// [`endpoint.updateDiscoKey`]: https://github.com/tailscale/tailscale/blob/9ea7cba44591e0cd840c6c94d23274dd222059bf/wgengine/magicsock/endpoint.go
117 fn update_from_control(&mut self, key: Option<DiscoPublicKey>) {
118 self.control = key;
119 self.tsmp_active = self.tsmp_active || key.is_none();
120 }
121
122 /// Replace the TSMP-learned key, leaving the control-learned key in place — Go
123 /// `endpoint.updateTSMPDiscoKey`.
124 fn update_from_tsmp(&mut self, key: Option<DiscoPublicKey>) {
125 self.tsmp = key;
126 self.tsmp_active = key.is_some();
127 }
128
129 /// The peer's other known key: the slot that is not active, when it holds a key that differs
130 /// from the active one.
131 ///
132 /// This is what makes ingress under the peer's *other* key resolvable
133 /// ([`PeerDb::set_inactive_disco_key`]). `None` when the inactive slot is empty or holds the
134 /// same key as the active one — there is no second key to accept in either case.
135 fn inactive_key(&self) -> Option<DiscoPublicKey> {
136 let inactive = if self.tsmp_active {
137 self.control
138 } else {
139 self.tsmp
140 };
141
142 inactive.filter(|k| Some(*k) != self.key())
143 }
144
145 /// Accept `key` as this peer's, switching the active slot to it when it is the currently
146 /// *inactive* one — Go [`endpoint.checkAndUpdateDiscoKey`].
147 ///
148 /// Called with the sender key of a disco frame we have opened, which proves the sender holds
149 /// that key's private half. Receiving under a key is therefore demonstrative: it is what the
150 /// peer is actually using, so upstream makes it the key we send to as well.
151 ///
152 /// Returns `None` when `key` belongs to **neither** slot — the refusal that is the whole
153 /// security value of the check, and the reason this is not simply "trust whatever key opened".
154 /// Otherwise `Some(changed)`, where `changed` reports whether the active key moved (and so
155 /// whether the direct path built under the old one has to be invalidated).
156 ///
157 /// [`endpoint.checkAndUpdateDiscoKey`]: https://github.com/tailscale/tailscale/blob/9ea7cba44591e0cd840c6c94d23274dd222059bf/wgengine/magicsock/endpoint.go
158 fn check_and_update(&mut self, key: DiscoPublicKey) -> Option<bool> {
159 if self.key() == Some(key) {
160 return Some(false);
161 }
162
163 // Not the active key. Go's compare-and-swap on `tsmpActive`: whichever slot holds it
164 // becomes the active one. Control's slot is tried first only for determinism — the two
165 // holding the same key is already handled by the equality check above.
166 if self.control == Some(key) {
167 self.tsmp_active = false;
168 return Some(true);
169 }
170 if self.tsmp == Some(key) {
171 self.tsmp_active = true;
172 return Some(true);
173 }
174
175 None
176 }
177
178 /// No key material from either source — Go nils out the endpoint's `disco` pointer here.
179 fn is_empty(&self) -> bool {
180 self.control.is_none() && self.tsmp.is_none()
181 }
182}
183
184/// Actor that tracks peer delta updates and emits new states.
185pub struct PeerTracker {
186 peer_db: PeerDb,
187 seen_state_update: bool,
188 pending_requests: Vec<Pending>,
189 /// Latest peer snapshot, published on every netmap update so embedders can watch for peer
190 /// changes ([`WatchNetmap`]).
191 peer_watch: watch::Sender<Vec<StatusNode>>,
192 /// Accumulated netmap user profiles (`MapResponse.UserProfiles`), keyed by user id, joined
193 /// against a node's [`Node::user_id`](ts_control::Node::user_id) to resolve the owning user's
194 /// login/display name for a [`WhoIs`](crate::status::WhoIs). Control sends these incrementally
195 /// (only new/changed profiles per response), so this map **accumulates** across updates rather
196 /// than being replaced — a peer upserted in one response may reference a profile delivered in an
197 /// earlier one.
198 user_profiles: HashMap<UserId, UserProfile>,
199 /// Per-peer disco-key provenance ([`EndpointDisco`]), keyed by the peer's node key.
200 ///
201 /// Go keeps this on the magicsock `endpoint`, which the peer map keys by node key; here the peer
202 /// db stores control's [`Node`] verbatim, so the second key (and which of the two is active)
203 /// lives beside it. Keying by node key reproduces Go's lifetime exactly: the state is dropped
204 /// when the peer leaves the netmap, and a peer that ROTATES its node key gets a fresh entry —
205 /// Go builds it a new endpoint, so a key learned over TSMP under the old node key is never
206 /// carried onto the new one. [`prune_endpoint_disco`](PeerTracker::prune_endpoint_disco) does
207 /// the dropping.
208 endpoint_disco: HashMap<NodePublicKey, EndpointDisco>,
209 /// Tailnet-Lock (TKA) authority enforced at the peer-trust chokepoint, matching Go
210 /// `tkaFilterNetmapLocked`. Read on demand from a [`watch`] cell the control runner owns: when it
211 /// holds `Some` (a verified lock has been synced from control), enforcement is **active** — every
212 /// upserted peer must present a `key_signature` this authority authorizes, or it is dropped
213 /// (fail-closed), exactly as Go drops peers with a missing or failing signature. When it holds
214 /// `None` (no lock, or the lock was disabled) enforcement is **inactive** and every peer is
215 /// upserted, identical to pre-TKA behavior and to Go's `b.tka == nil` early return.
216 ///
217 /// A `watch::Receiver` (not the bus) is the transport on purpose: the authority is a single
218 /// security-critical state cell, and `watch` is last-write-wins, never-dropped, and ordered by
219 /// the control runner's own writes — so a disable (`None`) can never be reordered behind or
220 /// silently dropped before a stale `Some` (which a best-effort broadcast bus could do, leaving a
221 /// defunct lock enforcing forever). The control runner is the sole writer; we only ever read.
222 ///
223 /// The authority always passes through `VerifiedAumChain::verify` before the control runner
224 /// publishes it, so enforcement only engages on a chain we have cryptographically verified.
225 /// Connectivity now depends on `ts_tka` verifying genuinely-good signatures correctly (see
226 /// SECURITY.md). Self is structurally never filtered here (the self node never enters `peer_db` —
227 /// it is routed to the control runner's `self_node` cell), so a node cannot lock itself out of
228 /// its own netmap.
229 tka_authority: watch::Receiver<Option<Arc<ts_tka::Authority>>>,
230 /// Node-key expiry enforcement — Go `ipnlocal.expiryManager` (`ipn/ipnlocal/expiry.go`).
231 ///
232 /// Holds the local-to-control clock delta (fed from `MapResponse.ControlTime`) and the set of
233 /// peers already flagged, and is the thing that actually rewrites an expired peer. It lives
234 /// here because the peer db is this fork's netmap: every site that installs a peer goes through
235 /// [`upsert_from_control`](PeerTracker::upsert_from_control), so putting the pass there is what
236 /// makes "no peer is ever installed unflagged" true by construction rather than by review.
237 expiry: ExpiryManager,
238 /// The most recent self node control sent, kept only so it can be folded into
239 /// [`ExpiryManager::next_peer_expiry`] exactly as Go folds in `nm.SelfNode` — this node's own
240 /// key expiry must arm the timer too. Never entered into the peer db (self is not a peer) and
241 /// never flagged here: the self-expiry *decision* is the control runner's (`expiry_action`).
242 self_node: Option<Node>,
243 /// The armed expiry timer — Go `LocalBackend.nmExpiryTimer`.
244 ///
245 /// Sleeps until the soonest future key expiry across the peers and the self node, then sends
246 /// [`ExpiryTimerFired`] so expiry is re-evaluated **when it happens** rather than whenever the
247 /// next netmap arrives. Re-armed (and the old one aborted) after every netmap and after every
248 /// firing. Aborting rather than letting a stale timer run is this fork's equivalent of Go's
249 /// `numClientStatusCalls` generation check.
250 expiry_timer: Option<tokio::task::JoinHandle<()>>,
251 env: Env,
252}
253
254impl PeerTracker {
255 fn peer_by_name_opt(&self, name: &str) -> Option<&Node> {
256 // Canonicalization (case + trailing dot) is handled inside the name index lookup.
257 self.peer_db.get(&name).map(|(_id, node)| node)
258 }
259
260 fn peer_by_tailnet_ip_opt(&self, ip: IpAddr) -> Option<&Node> {
261 self.peer_db.get(&ip).map(|(_id, node)| node)
262 }
263
264 /// Build the peer entries for a [`Status`](crate::Status) snapshot from the current peer db.
265 ///
266 /// Connectivity fields (`cur_addr`/`relay`) are left at their `from_node` defaults (`None`) here:
267 /// this is the live-watch/hot path and must stay magicsock-free and synchronous. The explicit
268 /// [`GetStatus`] snapshot enriches them ([`status_peers_with_ids`](Self::status_peers_with_ids)).
269 fn status_peers(&self) -> Vec<StatusNode> {
270 self.peer_db
271 .peers()
272 .values()
273 .map(StatusNode::from_node)
274 .collect()
275 }
276
277 /// Like [`status_peers`](Self::status_peers) but pairs each entry with its [`PeerId`], so the
278 /// caller can join per-peer connectivity (the direct manager's `best_addrs`, keyed by `PeerId`)
279 /// onto the `StatusNode` before returning it. Order is unspecified (a `HashMap` walk).
280 fn status_peers_with_ids(&self) -> Vec<(PeerId, StatusNode)> {
281 self.peer_db
282 .peers()
283 .iter()
284 .map(|(id, node)| (*id, StatusNode::from_node(node)))
285 .collect()
286 }
287
288 fn whois_opt(&self, addr: std::net::SocketAddr) -> Option<crate::status::WhoIs> {
289 let ip = crate::status::whois_addr(addr);
290 let node = self.peer_by_tailnet_ip_opt(ip).cloned()?;
291 // Join the node's owning user id against the accumulated UserProfiles table. `None` when
292 // control sent no profile for that user (e.g. tagged nodes with no human owner, or a
293 // profile not yet delivered). The whole profile is handed over, not a flattened label:
294 // `WhoIs` is what an embedder authorises on, and `UserProfile::groups` is the only owner
295 // attribute it cannot re-derive from the netmap itself.
296 let user_profile = self.resolve_user_profile(node.user_id);
297 Some(crate::status::WhoIs::from_node_with_profile(
298 node,
299 user_profile,
300 ))
301 }
302
303 /// Merge a response's `MapResponse.UserProfiles` into the accumulated table, keyed by user id.
304 ///
305 /// Control sends profiles incrementally — only new or changed ones per response — so this
306 /// **accumulates**: a profile already held for a user id that this response does not restate
307 /// stays, and one it does restate is replaced wholesale (control's newer copy wins, including
308 /// a group list that shrank).
309 fn accumulate_user_profiles(&mut self, profiles: &[UserProfile]) {
310 for profile in profiles {
311 self.user_profiles.insert(profile.id, profile.clone());
312 }
313 }
314
315 /// Resolve a user id to its profile from the accumulated profile table.
316 fn resolve_user_profile(&self, user_id: UserId) -> Option<UserProfile> {
317 self.user_profiles.get(&user_id).cloned()
318 }
319
320 /// Whether `node` may be admitted to the peer db under Tailnet Lock, matching Go
321 /// `tkaFilterNetmapLocked`'s per-peer verdict (drop unsigned / failed-signature peers).
322 ///
323 /// This consults the live [`tka_authority`](Self::tka_authority) cell on each call (one `borrow`,
324 /// held only for the duration of the verdict). For a `Full` resync — which checks every peer —
325 /// prefer [`tka_authority_snapshot`](Self::tka_authority_snapshot) +
326 /// [`tka_snapshot_admits`](Self::tka_snapshot_admits) to borrow once and verify each peer a single
327 /// time; this method is the convenience wrapper for the single-peer (`Delta`/patch) sites.
328 ///
329 /// Fail-closed and gated:
330 /// - No authority ⇒ no lock synced ⇒ always admit (Go's `b.tka == nil` early return; identical to
331 /// pre-TKA behavior).
332 /// - **Empty trusted-key state** ⇒ always admit (logged at `error!` — see
333 /// [`tka_snapshot_admits`](Self::tka_snapshot_admits) for the full rationale).
334 /// - Authority present + peer carries a `key_signature` the authority authorizes for the peer's
335 /// node key ⇒ admit.
336 /// - Authority present + signature missing or unauthorized/invalid ⇒ **drop** (Go drops peers
337 /// with a missing signature or failed `NodeKeyAuthorized` under tailnet lock).
338 fn tka_admits(&self, node: &Node) -> bool {
339 // Single-peer sites (`Delta`/patch) only need the admit bool; the rotation details are used
340 // exclusively by the cross-peer `Full` filter (rotation obsolescence is whole-netmap).
341 Self::tka_snapshot_admits(self.tka_authority.borrow().as_deref(), node).admitted
342 }
343
344 /// Borrow the current TKA authority once (cloning the cheap `Arc`) for a batch verdict. Returns
345 /// `None` when no lock is synced (admit-all). Used by the `Full` path so a netmap of N peers
346 /// reads the cell once and runs at most one signature verify per peer (not two).
347 fn tka_authority_snapshot(&self) -> Option<Arc<ts_tka::Authority>> {
348 self.tka_authority.borrow().clone()
349 }
350
351 /// The per-peer Tailnet-Lock verdict against an already-borrowed `authority` snapshot. Factored
352 /// out so both the single-peer [`tka_admits`](Self::tka_admits) and the `Full` batch path share
353 /// one verdict implementation (no divergence) while the batch path verifies each peer exactly
354 /// once.
355 ///
356 /// Returns whether the peer is admitted AND, for an admitted peer signed by a rotation chain, the
357 /// [`RotationDetails`](ts_tka::RotationDetails) of that chain — so the `Full` path can run the
358 /// cross-peer rotation filter (Go's `rotationTracker`) without a second verify per peer. A peer
359 /// that is dropped, unsigned, or signed by a non-rotation chain carries `rotation == None`.
360 ///
361 /// Never logs key/signature bytes — only the `stable_id` and the `TkaError` Display (static
362 /// descriptors). One documented parity gap remains vs Go (in PARITY_ROADMAP): no
363 /// `UnsignedPeerAPIOnly` *admission* exemption — Go admits such a peer unsigned under an active
364 /// lock, we drop it (stricter, the safe direction). [`Node::unsigned_peer_api_only`] is now
365 /// carried, and the routes half of upstream's treatment is enforced at decode
366 /// (`ts_control::Node`'s `From` impl clamps such a peer's accepted routes to its own addresses,
367 /// unconditionally, whether or not a lock is active); only the admission carve-out is deferred.
368 fn tka_snapshot_admits(authority: Option<&ts_tka::Authority>, node: &Node) -> TkaVerdict {
369 let Some(auth) = authority else {
370 return TkaVerdict::admit();
371 };
372
373 // Brick-guard: an authority with no trusted keys would drop every peer. A verified chain is
374 // structurally guaranteed ≥1 key (genesis rejects an empty key set, and the last key cannot
375 // be removed), so reaching here means a `ts_tka` invariant was violated — admit rather than
376 // black-hole the whole netmap, and log at `error!` because it signals a real bug, not an
377 // expected runtime input. This is OUR fail-safe, not a Go behavior. NOTE: it only catches the
378 // empty-keyset shape; a non-empty authority that authorizes none of the offered peers still
379 // (correctly) drops them — that is what a lock that revoked everyone means. The
380 // "authorized-zero-peers" isolation case is surfaced separately by the caller.
381 if auth.state().keys.is_empty() {
382 tracing::error!(
383 "TKA: authority has an empty trusted-key set (verified chains never do — likely a \
384 ts_tka bug); not enforcing (admitting all) to avoid isolating the node"
385 );
386 return TkaVerdict::admit();
387 }
388
389 if node.key_signature.is_empty() {
390 tracing::warn!(
391 stable_id = ?node.stable_id,
392 "TKA: dropping unsigned peer under tailnet lock"
393 );
394 return TkaVerdict::drop();
395 }
396
397 match auth.node_key_authorized_with_details(&node.node_key.to_bytes(), &node.key_signature)
398 {
399 Ok(rotation) => {
400 tracing::debug!(stable_id = ?node.stable_id, "TKA: peer node-key authorized");
401 TkaVerdict {
402 admitted: true,
403 rotation,
404 }
405 }
406 Err(e) => {
407 tracing::warn!(
408 stable_id = ?node.stable_id,
409 error = %e,
410 "TKA: dropping peer with unauthorized node key"
411 );
412 TkaVerdict::drop()
413 }
414 }
415 }
416
417 /// The **keep** verdict for a whole batch of peers under `authority` — one complete Go
418 /// `tkaFilterNetmapLocked` pass (`ipn/ipnlocal/tailnet-lock.go`, v1.100.0), in Go's order:
419 ///
420 /// 1. the per-peer signature verdict ([`tka_snapshot_admits`](Self::tka_snapshot_admits)), then
421 /// 2. the cross-peer rotation filter (Go `rotationTracker`): a peer presenting a node key that a
422 /// newer rotation has superseded — or a tied clone of one — is dropped even though its own
423 /// signature verifies. That is whole-batch by nature (one peer's chain obsoletes another's
424 /// key), which is why it lives here and not in the per-peer verdict.
425 ///
426 /// Factored out because two call sites must agree exactly on what "admitted" means: the `Full`
427 /// netmap upsert in [`apply_peer_update`](Self::apply_peer_update), and
428 /// [`tka_reevaluate_peer_db`](Self::tka_reevaluate_peer_db), which re-runs the same pass over the
429 /// peers already in the db when a freshly-synced authority is installed. A divergence between
430 /// them would be a peer admitted by one path and dropped by the other.
431 ///
432 /// `authority` is borrowed once and each peer verified exactly once (the ed25519 verify is the
433 /// expensive part). Returns one `bool` per input node, in input order; `None` authority ⇒ all
434 /// `true` (no lock synced ⇒ admit all, Go's `b.tka == nil` early return).
435 ///
436 /// `pub(crate)` for a third caller with the same requirement: the cold-start replay of a cached
437 /// netmap ([`control_runner::load_cached_netmap`](crate::control_runner::load_cached_netmap)),
438 /// which must apply the same pass to the cached peers that the netmap they were cached from
439 /// already went through — Go replays its cached map through `setNetMapLocked`, so it runs this
440 /// very filter.
441 pub(crate) fn tka_keep_verdicts(
442 authority: Option<&ts_tka::Authority>,
443 nodes: &[&Node],
444 ) -> Vec<bool> {
445 let verdicts = nodes
446 .iter()
447 .map(|node| Self::tka_snapshot_admits(authority, node))
448 .collect::<Vec<_>>();
449
450 let mut rotation = RotationTracker::default();
451 for (node, verdict) in nodes.iter().zip(&verdicts) {
452 if verdict.admitted
453 && let Some(details) = &verdict.rotation
454 {
455 rotation.add(node.node_key.to_bytes().to_vec(), details);
456 }
457 }
458 let obsolete = rotation.obsolete_keys();
459
460 nodes
461 .iter()
462 .zip(&verdicts)
463 .map(|(node, v)| {
464 // `contains` takes `&[u8]` (HashSet<Vec<u8>> borrows as a slice) — no alloc.
465 v.admitted && !obsolete.contains(&node.node_key.to_bytes()[..])
466 })
467 .collect()
468 }
469}
470
471/// The outcome of a per-peer Tailnet-Lock check: whether the peer is admitted, plus (for an admitted
472/// peer signed by a rotation chain) the chain's [`RotationDetails`](ts_tka::RotationDetails) so the
473/// `Full` path can run the cross-peer rotation filter from the SAME verify pass (no second verify).
474struct TkaVerdict {
475 admitted: bool,
476 rotation: Option<ts_tka::RotationDetails>,
477}
478
479impl TkaVerdict {
480 /// Admitted, no rotation details (no lock / brick-guard / non-rotation signature).
481 fn admit() -> Self {
482 Self {
483 admitted: true,
484 rotation: None,
485 }
486 }
487 /// Dropped.
488 fn drop() -> Self {
489 Self {
490 admitted: false,
491 rotation: None,
492 }
493 }
494}
495
496/// Cross-peer rotation-obsolescence tracker, mirroring Go `ipnlocal.rotationTracker`. Fed the
497/// [`RotationDetails`](ts_tka::RotationDetails) of every admitted, rotation-signed peer in a `Full`
498/// netmap; [`obsolete_keys`](Self::obsolete_keys) then returns the node keys to drop on top of the
499/// per-peer verdict. Two rules (Go `tkaFilterNetmapLocked` + `rotationTracker.obsoleteKeys`):
500///
501/// 1. Every prior node key named in any rotation chain is obsolete (a newer chain rotated it away).
502/// 2. Among `Direct`-rooted chains sharing one wrapping pubkey (a clone signal), only the
503/// longest-chain peer survives; if the two longest are tied, ALL in that group are dropped (we
504/// cannot tell which is the latest, so reject for safety). `Credential`-rooted chains are exempt
505/// from rule 2 — several nodes can legitimately join under one reusable auth key (same wrapping
506/// pubkey), so sharing it is not a clone signal there. (Rule 1 still applies to them.)
507///
508/// Node keys are tracked as raw `Vec<u8>` (the verified 32-byte node-public bytes).
509#[derive(Default)]
510struct RotationTracker {
511 obsolete: HashSet<Vec<u8>>,
512 by_wrapping_key: HashMap<Vec<u8>, Vec<SigRotation>>,
513}
514
515/// One admitted peer's rotation entry within a wrapping-key group.
516struct SigRotation {
517 node_key: Vec<u8>,
518 num_prev_keys: usize,
519}
520
521impl RotationTracker {
522 /// Record an admitted peer `node_key` and its rotation `details` (Go `addRotationDetails`).
523 fn add(&mut self, node_key: Vec<u8>, details: &ts_tka::RotationDetails) {
524 // Rule 1: every prior key is obsolete — applied for ALL chains (incl. credential-rooted),
525 // matching Go's ungated `obsolete.AddSlice(d.PrevNodeKeys)`.
526 self.obsolete.extend(details.prev_node_keys.iter().cloned());
527 // Rule 2 (clone-uniqueness) is gated to Direct-rooted chains only.
528 if details.initial_sig_kind != ts_tka::SigKind::Direct {
529 return;
530 }
531 self.by_wrapping_key
532 .entry(details.initial_wrapping_pubkey.clone())
533 .or_default()
534 .push(SigRotation {
535 node_key,
536 num_prev_keys: details.prev_node_keys.len(),
537 });
538 }
539
540 /// Compute the full obsolete node-key set (Go `rotationTracker.obsoleteKeys`). Processes each
541 /// wrapping-key group, mutating the shared `obsolete` set as it goes (so a key obsoleted by one
542 /// group is seen as obsolete by later groups via the `retain` below — Go's
543 /// `slices.DeleteFunc(... Contains)`). Group iteration order (a `HashMap` drain) is
544 /// nondeterministic, but the result is order-INDEPENDENT: this only ever *inserts* into
545 /// `obsolete` (never removes), and rule 1 already obsoleted every prior key before this loop, so
546 /// the final set is a union that does not depend on which group runs first (as in Go).
547 fn obsolete_keys(mut self) -> HashSet<Vec<u8>> {
548 // Drain only the group map so the loop can mutate `self.obsolete` without aliasing it; the
549 // shared `obsolete` set itself is NOT drained, preserving the cross-group visibility above.
550 let groups: Vec<Vec<SigRotation>> = self.by_wrapping_key.drain().map(|(_k, v)| v).collect();
551 for mut group in groups {
552 // Drop entries already obsoleted (rotated away) by another chain.
553 group.retain(|rd| !self.obsolete.contains(&rd.node_key));
554 if group.is_empty() {
555 continue;
556 }
557 // Longest chain (most prior keys) is the newest ⇒ the survivor; sort decreasing.
558 // `sort_by_key` is stable (like Go's `SortStableFunc`); `Reverse` gives descending order.
559 group.sort_by_key(|rd| core::cmp::Reverse(rd.num_prev_keys));
560 if group.len() >= 2 && group[0].num_prev_keys == group[1].num_prev_keys {
561 // Tie for longest ⇒ cannot disambiguate the latest ⇒ drop the WHOLE group.
562 tracing::warn!(
563 "TKA: multiple peers share a wrapping key with equal rotation depth; dropping all (cannot determine the latest)"
564 );
565 for rd in &group {
566 self.obsolete.insert(rd.node_key.clone());
567 }
568 } else {
569 // Only the longest-chain peer survives; the rest are obsolete.
570 for rd in &group[1..] {
571 self.obsolete.insert(rd.node_key.clone());
572 }
573 }
574 }
575 self.obsolete
576 }
577}
578
579impl kameo::Actor for PeerTracker {
580 /// `(env, tka_authority)`: the bus/keys env, plus the read end of the control runner's TKA
581 /// enforcement-authority cell (Go `tkaFilterNetmapLocked`). The control runner is the sole
582 /// writer; it publishes the verified `Authority` after a successful `/machine/tka/sync` and
583 /// `None` when the lock is disabled. A `watch` cell (not a bus message) so the latest value is
584 /// always readable on demand, never dropped, and never reordered (see the control runner's
585 /// `tka_authority` cell).
586 type Args = (Env, watch::Receiver<Option<Arc<ts_tka::Authority>>>);
587 type Error = Error;
588
589 async fn on_start(
590 (env, tka_authority): Self::Args,
591 slf: ActorRef<Self>,
592 ) -> Result<Self, Self::Error> {
593 env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
594 env.subscribe::<PeerDiscoKeyAdvertisement>(&slf).await?;
595 env.subscribe::<DiscoKeyObserved>(&slf).await?;
596
597 // Re-filter the peer db whenever the enforcement authority changes. Go gets this for free:
598 // `SetControlClientStatus` runs `tkaSyncIfNeeded` and `tkaFilterNetmapLocked` back to back
599 // over one netmap. Here the sync is asynchronous, so the peers admitted before the authority
600 // arrived need a second pass — see `tka_reevaluate_peer_db`. `changed()` resolves on every
601 // write to the cell (enable, re-sync, disable); the task ends when the control runner drops
602 // the sender (shutdown) or the tracker itself is gone.
603 //
604 // A **weak** ref on purpose: the runtime holds only a `WeakActorRef` to the peer tracker, so
605 // a strong one parked in this task would keep the actor's mailbox alive past shutdown.
606 let mut authority_changes = tka_authority.clone();
607 let notify = slf.downgrade();
608 tokio::spawn(async move {
609 while authority_changes.changed().await.is_ok() {
610 let Some(tracker) = notify.upgrade() else {
611 break; // the peer tracker is gone; nothing left to re-filter
612 };
613 if tracker.tell(TkaAuthorityChanged).await.is_err() {
614 break; // the peer tracker stopped
615 }
616 }
617 });
618
619 let (peer_watch, _) = watch::channel(Vec::new());
620
621 Ok(Self {
622 peer_db: PeerDb::default(),
623 pending_requests: Default::default(),
624 seen_state_update: false,
625 peer_watch,
626 user_profiles: HashMap::new(),
627 endpoint_disco: HashMap::new(),
628 // The cell starts `None` (no lock synced ⇒ enforcement inactive, admit all, matching
629 // Go's `b.tka == nil`); the control runner flips it to `Some` on the first sync.
630 tka_authority,
631 expiry: ExpiryManager::new(),
632 self_node: None,
633 expiry_timer: None,
634 env,
635 })
636 }
637}
638
639enum Pending {
640 PeerByName(PeerByName, ReplySender<Option<Node>>),
641 AcceptedRoute(PeerByAcceptedRoute, ReplySender<Vec<Node>>),
642 TailnetIp(PeerByTailnetIp, ReplySender<Option<Node>>),
643 Status(ReplySender<Vec<(PeerId, StatusNode)>>),
644 WhoIs(Whois, ReplySender<Option<crate::status::WhoIs>>),
645}
646
647// For messages with arguments, a struct is generated with the args as fields. They aren't
648// documented, and we can't apply attributes directly to the fields. Hence, wrap in a module where
649// docs are turned off everywhere.
650#[allow(missing_docs)]
651mod msg_impl {
652 use std::net::IpAddr;
653
654 use kameo::prelude::DelegatedReply;
655
656 use super::*;
657
658 #[kameo::messages]
659 impl PeerTracker {
660 /// Lookup a peer by name.
661 ///
662 /// Waits until we've received at least one peer update from control.
663 #[message(ctx)]
664 pub async fn peer_by_name(
665 &mut self,
666 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
667 name: String,
668 ) -> DelegatedReply<Option<Node>> {
669 let (deleg, sender) = ctx.reply_sender();
670 let Some(sender) = sender else { return deleg };
671
672 if !self.seen_state_update {
673 tracing::debug!(query = name, "no peer state seen yet, queueing request");
674
675 self.pending_requests
676 .push(Pending::PeerByName(PeerByName { name }, sender));
677
678 return deleg;
679 }
680
681 sender.send(self.peer_by_name_opt(&name).cloned());
682
683 deleg
684 }
685
686 /// Lookup all peers that accept packets addressed to the given IP.
687 ///
688 /// This includes the peer's tailnet address and any subnet routes it provides. Only
689 /// the peers with the most specific subnet route match that covers `ip` will be
690 /// returned.
691 ///
692 /// E.g., suppose:
693 ///
694 /// - We're querying for `10.1.2.3`
695 /// - `PeerA` and `PeerB` have accepted routes for `10.1.2.0/24`
696 /// - `PeerC` has an accepted route for `10.1.0.0/16`
697 ///
698 /// Only `PeerA` and `PeerB` will be returned, since they have the most specific
699 /// prefix match.
700 #[message(ctx)]
701 pub fn peer_by_accepted_route(
702 &mut self,
703 ctx: &mut Context<Self, DelegatedReply<Vec<Node>>>,
704 ip: IpAddr,
705 ) -> DelegatedReply<Vec<Node>> {
706 let (deleg, sender) = ctx.reply_sender();
707 let Some(sender) = sender else { return deleg };
708
709 if !self.seen_state_update {
710 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
711
712 self.pending_requests
713 .push(Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, sender));
714
715 return deleg;
716 }
717
718 sender.send(
719 self.peer_db
720 .get_route(ip.into())
721 .map(|(_id, node)| node.clone())
722 .collect(),
723 );
724
725 deleg
726 }
727
728 /// Lookup the peer that has the given tailnet IP address.
729 #[message(ctx)]
730 pub fn peer_by_tailnet_ip(
731 &mut self,
732 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
733 ip: IpAddr,
734 ) -> DelegatedReply<Option<Node>> {
735 let (deleg, sender) = ctx.reply_sender();
736 let Some(sender) = sender else { return deleg };
737
738 if !self.seen_state_update {
739 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
740
741 self.pending_requests
742 .push(Pending::TailnetIp(PeerByTailnetIp { ip }, sender));
743
744 return deleg;
745 }
746
747 sender.send(self.peer_by_tailnet_ip_opt(ip).cloned());
748
749 deleg
750 }
751
752 /// Build the peer entries of a [`Status`](crate::Status) snapshot, each paired with its
753 /// [`PeerId`] so [`Runtime::status`](crate::Runtime::status) can join per-peer connectivity
754 /// (`cur_addr`/`relay`) from the direct manager before returning. The self node is *not*
755 /// included here (it lives in the control runner); `Runtime::status` combines both and drops
756 /// the ids.
757 ///
758 /// Waits until we've received at least one peer update from control.
759 #[message(ctx)]
760 pub fn get_status(
761 &mut self,
762 ctx: &mut Context<Self, DelegatedReply<Vec<(PeerId, StatusNode)>>>,
763 ) -> DelegatedReply<Vec<(PeerId, StatusNode)>> {
764 let (deleg, sender) = ctx.reply_sender();
765 let Some(sender) = sender else { return deleg };
766
767 if !self.seen_state_update {
768 tracing::debug!("no peer state seen yet, queueing status request");
769 self.pending_requests.push(Pending::Status(sender));
770 return deleg;
771 }
772
773 sender.send(self.status_peers_with_ids());
774
775 deleg
776 }
777
778 /// Return every known peer's full domain [`Node`] (not the lossy [`StatusNode`]).
779 ///
780 /// Used by [`Runtime::file_targets`](crate::Runtime::file_targets), which needs the full node
781 /// (peerAPI address, owning user id, cap map) to compute Taildrop send targets. The self node
782 /// is not included (it lives in the control runner). Returns empty before the first netmap —
783 /// the natural "not connected yet" analog (an immediate answer, no queueing needed: callers
784 /// that need a populated list await `Running` first).
785 #[message]
786 pub fn all_peers(&self) -> Vec<Node> {
787 self.peer_db.peers().values().cloned().collect()
788 }
789
790 /// Look up a peer by its control-assigned stable node id ([`Node::stable_id`]).
791 ///
792 /// The lookup a caller holding an older [`Node`] snapshot uses to refresh it before acting
793 /// on it — notably the Taildrop send path (`tailscale::Device::send_file`), which must not
794 /// dial a peer this node has since flagged expired. `None` means the db holds no peer with
795 /// that id: either it has left the tailnet, or no netmap has arrived yet.
796 ///
797 /// Answers immediately in both cases; unlike [`PeerByName`] it does **not** queue until the
798 /// first peer update. A caller that already holds a snapshot has one to fall back on, and
799 /// blocking a send behind a netmap that may never come would be worse than answering from
800 /// what is known.
801 #[message]
802 pub fn peer_by_stable_id(&self, stable_id: ts_control::StableNodeId) -> Option<Node> {
803 self.peer_db.get(&stable_id).map(|(_id, node)| node.clone())
804 }
805
806 /// Resolve which node owns a tailnet source address.
807 ///
808 /// Maps the source IP of `addr` to the owning node via the tailnet-IP index, returning a
809 /// [`WhoIs`](crate::WhoIs). The port is ignored (a tailnet IP uniquely identifies a node).
810 ///
811 /// The resulting [`WhoIs`](crate::WhoIs) carries no user/login or capability data: this
812 /// fork's domain [`Node`] does not retain those wire fields. See the
813 /// [`status`](crate::status) module docs for the gap.
814 ///
815 /// Waits until we've received at least one peer update from control.
816 #[message(ctx)]
817 pub fn whois(
818 &mut self,
819 ctx: &mut Context<Self, DelegatedReply<Option<crate::status::WhoIs>>>,
820 addr: std::net::SocketAddr,
821 ) -> DelegatedReply<Option<crate::status::WhoIs>> {
822 let (deleg, sender) = ctx.reply_sender();
823 let Some(sender) = sender else { return deleg };
824
825 if !self.seen_state_update {
826 tracing::debug!(query = %addr, "no peer state seen yet, queueing whois request");
827 self.pending_requests
828 .push(Pending::WhoIs(Whois { addr }, sender));
829 return deleg;
830 }
831
832 sender.send(self.whois_opt(addr));
833
834 deleg
835 }
836
837 /// Subscribe to netmap peer-change events.
838 ///
839 /// Returns a [`watch::Receiver`] whose value is the current set of peer
840 /// [`StatusNode`]s, updated on every netmap state update from control. Embedders can await
841 /// changes via [`watch::Receiver::changed`] to react to peers joining, leaving, or changing.
842 ///
843 /// The receiver's initial value is the peer set at subscription time (empty before the
844 /// first netmap update). This is a peer-only view; combine with the self node from
845 /// [`Runtime::status`](crate::Runtime::status) when a full snapshot is needed.
846 #[message(derive(Clone))]
847 pub fn watch_netmap(&self) -> watch::Receiver<Vec<StatusNode>> {
848 self.peer_watch.subscribe()
849 }
850 }
851}
852
853pub use msg_impl::*;
854
855#[derive(Debug, Clone)]
856pub(crate) struct PeerState {
857 #[allow(unused)]
858 pub deletions: HashSet<PeerId>,
859 #[allow(unused)]
860 pub upserts: HashSet<PeerId>,
861 pub peers: Arc<PeerDb>,
862 /// Control's `silent-disco` node attribute, read off the **self** node
863 /// ([`ts_control::Node::silent_disco`]) and carried with the peer snapshot so
864 /// [`crate::direct::DirectManager`] can push it onto the magicsock.
865 ///
866 /// It rides this message rather than a channel of its own because that is the shape it has
867 /// upstream: Go hands `debugFlagsLocked().heartbeatDisabled` to `endpoint.updateFromNode` while
868 /// applying the netmap, so the flag and the peer set land on the endpoints together. A snapshot
869 /// published for some other reason (a disco-key switch, an expiry, a TKA eviction) carries the
870 /// attribute's current value too, which is harmless — the push is idempotent.
871 pub silent_disco: bool,
872}
873
874impl Message<Arc<ts_control::StateUpdate>> for PeerTracker {
875 type Reply = ();
876
877 async fn handle(
878 &mut self,
879 msg: Arc<ts_control::StateUpdate>,
880 ctx: &mut Context<Self, Self::Reply>,
881 ) {
882 // Accumulate user profiles first — control sends them incrementally and a response may
883 // carry profiles with no peer delta (or peers that reference a profile from an earlier
884 // response), so this must happen before the no-peer-update early return below.
885 self.accumulate_user_profiles(&msg.user_profiles);
886
887 // Wall clock for everything below, sampled once so one response is evaluated at one
888 // instant. chrono is built without its `clock` feature in this workspace, so `local_now`
889 // derives it from `SystemTime` the same way the control runner / ssh-policy paths do.
890 let now = local_now();
891
892 // Record control's own clock BEFORE anything reads expiry — Go `onControlTime`, delivered
893 // to the expiry manager as its own event. From here on every expiry comparison is made
894 // against control's time, not this host's, so a node with a skewed clock neither expires
895 // peers early nor misses that they expired at all.
896 if let Some(control_time) = msg.control_time {
897 let delta = self.expiry.on_control_time(control_time, now);
898 if !delta.is_zero() {
899 tracing::debug!(
900 delta_secs = delta.num_seconds(),
901 "control's clock differs from ours; expiry is judged against control's time"
902 );
903 }
904 }
905
906 // Remember the self node so it can be folded into the next-expiry computation below, exactly as Go
907 // folds `nm.SelfNode` into `nextPeerExpiry`. Self is never a peer and is never flagged
908 // here; the runtime's own expiry decision stays with the control runner.
909 if let Some(self_node) = msg.node.as_ref() {
910 self.self_node = Some(self_node.clone());
911 }
912
913 // Apply the standalone online/last-seen delta maps (channels C/D, `MapResponse.OnlineChange`
914 // / `PeerSeenChange`). These arrive keyed by control node id and may ride a response that
915 // carries NO `peer_update` (a bare online flip is the common case), so they must be applied
916 // *before* the no-peer-update early return — otherwise online status freezes at the last
917 // full-node/patch value. Each entry only ever *sets* a value (never back to unknown).
918 // `now` (above) is also the wall clock for a `PeerSeenChange: true` (Go uses `clock.Now()`).
919 let liveness_changed =
920 self.apply_liveness_changes(&msg.online_change, &msg.peer_seen_change, now);
921
922 if msg.peer_update.is_none() && msg.peer_patches.is_empty() {
923 // No peer set or patch, so the peer expiries are unchanged — but the self node or the
924 // clock delta may have moved, so the timer still has to be re-aimed.
925 self.rearm_expiry_timer(now, ctx.actor_ref());
926
927 // A response carrying the self node is a full netmap even without `Peers`: control
928 // omits the field when the peer list is empty (`omitempty`), so a node that may see no
929 // peers never gets a peer set at all. Its (empty) peer state is known now; don't keep
930 // peer queries waiting for a peer set that will never come.
931 if msg.node.is_some() && !self.seen_state_update {
932 self.service_pending_requests();
933 self.peer_watch.send_replace(self.status_peers());
934 }
935
936 // No peer set or patch this response. If a liveness delta still mutated the netmap,
937 // publish the refreshed snapshot so watchers (and `GetStatus`) see the new online state.
938 if liveness_changed {
939 self.service_pending_requests();
940 self.peer_watch.send_replace(self.status_peers());
941 if let Err(e) = self
942 .env
943 .publish(Arc::new(PeerState {
944 upserts: HashSet::default(),
945 deletions: HashSet::default(),
946 peers: Arc::new(self.peer_db.clone()),
947 silent_disco: self.silent_disco(),
948 }))
949 .await
950 {
951 tracing::error!(error = %e, "publishing liveness-only peer state update");
952 }
953 }
954 return;
955 }
956
957 // Apply the whole-node peer set (if any) FIRST, then the field-level patches on top —
958 // mirroring Go's `controlclient` order (`Peers*` then `PeersChangedPatch`). A response may
959 // carry either, both, or (with a liveness-only delta) neither. Merge the upsert/deletion sets
960 // so the published `PeerState` reflects every node touched by both passes; a node both
961 // upserted by the set and patched stays in `upserts` (the patch removes it from `deletions`).
962 let (mut upserts, mut deletions) = msg
963 .peer_update
964 .as_ref()
965 .map(|u| self.apply_peer_update(u, now))
966 .unwrap_or_default();
967
968 if !msg.peer_patches.is_empty() {
969 // `apply_peer_patch_set`, not `apply_peer_patches`: control can switch this node off
970 // the incremental path with the `disable-delta-updates` node attribute, in which case
971 // the same patches are applied as a full netmap update instead of as mutations.
972 let (patch_upserts, patch_deletions) =
973 self.apply_peer_patch_set(&msg.peer_patches, now);
974 // A patch can evict a node the set just upserted (TKA rejection after key rotation), or
975 // re-admit/patch one not in the set — reconcile so each id lands in exactly one set.
976 for id in &patch_upserts {
977 deletions.remove(id);
978 }
979 for id in &patch_deletions {
980 upserts.remove(id);
981 }
982 upserts.extend(patch_upserts);
983 deletions.extend(patch_deletions);
984 }
985
986 tracing::debug!(
987 n_upsert = upserts.len(),
988 n_delete = deletions.len(),
989 peer_count = self.peer_db.peers().len(),
990 "new peer state"
991 );
992
993 // Aim the timer at the soonest expiry in the peer set this response just installed — Go
994 // `setControlClientStatusLocked`, which stops the old timer and starts a new one on every
995 // netmap. Peers already past their expiry were flagged on the way in, so what is left is
996 // strictly in the future.
997 self.rearm_expiry_timer(now, ctx.actor_ref());
998
999 self.service_pending_requests();
1000
1001 // Publish the latest peer snapshot to netmap watchers. `send_replace` keeps the receiver's
1002 // value current even when there are no subscribers, so a late subscriber sees fresh state.
1003 self.peer_watch.send_replace(self.status_peers());
1004
1005 if let Err(e) = self
1006 .env
1007 .publish(Arc::new(PeerState {
1008 upserts,
1009 deletions,
1010 peers: Arc::new(self.peer_db.clone()),
1011 silent_disco: self.silent_disco(),
1012 }))
1013 .await
1014 {
1015 tracing::error!(error = %e, "publishing peer state update");
1016 }
1017 }
1018}
1019
1020impl Message<PeerDiscoKeyAdvertisement> for PeerTracker {
1021 type Reply = ();
1022
1023 async fn handle(
1024 &mut self,
1025 msg: PeerDiscoKeyAdvertisement,
1026 _ctx: &mut Context<Self, Self::Reply>,
1027 ) {
1028 if !self.learn_disco_key(msg.peer, msg.key) {
1029 return;
1030 }
1031
1032 // The key changed, so republish: the direct-path machinery resolves a peer's disco key out
1033 // of the published `PeerState` snapshot (`direct::DiscoPeerLookup`), which is the whole
1034 // point of learning it — it is what lets disco reach this peer without waiting for a
1035 // netmap update. Go does the equivalent by writing the key straight into the magicsock
1036 // endpoint and re-keying its peer map.
1037 self.peer_watch.send_replace(self.status_peers());
1038
1039 if let Err(e) = self
1040 .env
1041 .publish(Arc::new(PeerState {
1042 upserts: HashSet::from_iter([msg.peer]),
1043 deletions: HashSet::default(),
1044 peers: Arc::new(self.peer_db.clone()),
1045 silent_disco: self.silent_disco(),
1046 }))
1047 .await
1048 {
1049 tracing::error!(error = %e, "publishing peer state after a TSMP disco-key advertisement");
1050 }
1051 }
1052}
1053
1054impl Message<DiscoKeyObserved> for PeerTracker {
1055 type Reply = ();
1056
1057 async fn handle(&mut self, msg: DiscoKeyObserved, _ctx: &mut Context<Self, Self::Reply>) {
1058 if !self.observe_disco_key(msg.peer, msg.key) {
1059 return;
1060 }
1061
1062 // The active key moved, so republish. This is the *same* channel a TSMP advertisement and a
1063 // netmap disco-key change use, and it is what makes the direct manager invalidate the
1064 // trusted path built under the old key: it diffs consecutive snapshots
1065 // (`direct::disco_key_rotations`) and calls `MagicSock::changed_active_disco` — this fork's
1066 // `endpoint.changedActiveDiscoLocked`, which Go likewise reaches from
1067 // `checkAndUpdateDiscoKey`. Keeping the switch and the invalidation on one path is why the
1068 // switch is done here rather than on the packet path that spotted it.
1069 self.peer_watch.send_replace(self.status_peers());
1070
1071 if let Err(e) = self
1072 .env
1073 .publish(Arc::new(PeerState {
1074 upserts: HashSet::from_iter([msg.peer]),
1075 deletions: HashSet::default(),
1076 peers: Arc::new(self.peer_db.clone()),
1077 silent_disco: self.silent_disco(),
1078 }))
1079 .await
1080 {
1081 tracing::error!(error = %e, "publishing peer state after a disco active-key switch");
1082 }
1083 }
1084}
1085
1086/// Internal self-message: the armed expiry timer fired — the soonest key expiry the peer set knew
1087/// about has now passed, so expiry must be re-evaluated.
1088///
1089/// This is the whole point of the timer (Go `LocalBackend.nmExpiryTimer` →
1090/// `handleNetmapExpiry`): without it a peer whose key expires between two netmaps stays fully
1091/// configured — endpoints, DERP home, live node key — until control happens to send another
1092/// response, which on a steady map poll may be a long time.
1093///
1094/// Upstream `0640312e5` had to fix this path, because the timer there closed over the netmap
1095/// captured when it was armed and reinstalling that stale copy rolled back any delta that arrived
1096/// meanwhile; the fix re-reads live peer state before reinstalling. Here the pass reads the peer db
1097/// — the live state — directly, so there is no captured copy to roll anything back.
1098#[derive(Debug, Clone, Copy)]
1099pub(crate) struct ExpiryTimerFired;
1100
1101impl Message<ExpiryTimerFired> for PeerTracker {
1102 type Reply = ();
1103
1104 async fn handle(&mut self, _msg: ExpiryTimerFired, ctx: &mut Context<Self, Self::Reply>) {
1105 let now = local_now();
1106 let upserts = self.reevaluate_expiry(now);
1107
1108 // Re-aim at the next expiry after this one, whether or not anything was flagged: a timer
1109 // that fired early (clock skew, or the slack) must not be the last one armed.
1110 self.rearm_expiry_timer(now, ctx.actor_ref());
1111
1112 if upserts.is_empty() {
1113 return;
1114 }
1115
1116 // A newly expired peer lost its endpoints, its DERP home and its node key, so the
1117 // dataplane, route updater and source filter all have to see the new snapshot — the same
1118 // publish the netmap handler does after a peer set changes.
1119 self.service_pending_requests();
1120 self.peer_watch.send_replace(self.status_peers());
1121
1122 if let Err(e) = self
1123 .env
1124 .publish(Arc::new(PeerState {
1125 upserts,
1126 deletions: HashSet::default(),
1127 peers: Arc::new(self.peer_db.clone()),
1128 silent_disco: self.silent_disco(),
1129 }))
1130 .await
1131 {
1132 tracing::error!(error = %e, "publishing peer state after a peer key expired");
1133 }
1134 }
1135}
1136
1137/// Internal self-message: the Tailnet-Lock enforcement-authority cell changed — the control runner
1138/// installed a freshly-synced [`Authority`](ts_tka::Authority) after a `/machine/tka/sync`, or
1139/// cleared it because the lock was disabled. Sent by the watch task
1140/// [`on_start`](kameo::Actor::on_start) spawns, so the peer db is re-filtered the moment enforcement
1141/// changes instead of at whatever later `Full` netmap happens to arrive.
1142#[derive(Debug, Clone, Copy)]
1143pub(crate) struct TkaAuthorityChanged;
1144
1145impl Message<TkaAuthorityChanged> for PeerTracker {
1146 type Reply = ();
1147
1148 async fn handle(&mut self, _msg: TkaAuthorityChanged, _ctx: &mut Context<Self, Self::Reply>) {
1149 let deletions = self.tka_reevaluate_peer_db();
1150 if deletions.is_empty() {
1151 // The common case: enforcement is inactive, or every admitted peer still verifies.
1152 return;
1153 }
1154
1155 // An evicted peer must lose its data path, not just its db row, so republish the snapshot
1156 // the `Arc<PeerState>` subscribers (route updater, source filter, dataplane) resolve
1157 // against — the same publish the netmap handler does after a peer set changes.
1158 self.peer_watch.send_replace(self.status_peers());
1159
1160 if let Err(e) = self
1161 .env
1162 .publish(Arc::new(PeerState {
1163 upserts: HashSet::default(),
1164 deletions,
1165 peers: Arc::new(self.peer_db.clone()),
1166 silent_disco: self.silent_disco(),
1167 }))
1168 .await
1169 {
1170 tracing::error!(error = %e, "publishing peer state after a TKA authority change");
1171 }
1172 }
1173}
1174
1175/// Ask the peer tracker to re-broadcast its current peer snapshot on the bus, without any peer
1176/// change. Sent after a runtime preference change so the route updater and source filter (both
1177/// `Arc<PeerState>` subscribers) re-resolve against the new value immediately, rather than waiting
1178/// for the next netmap update: `Device::set_exit_node` (new exit-node selector) and
1179/// `Device::set_accept_routes` (new accept-routes flag) both send it.
1180#[derive(Debug, Clone, Copy)]
1181pub struct RepublishState;
1182
1183impl Message<RepublishState> for PeerTracker {
1184 type Reply = ();
1185
1186 async fn handle(&mut self, _msg: RepublishState, _ctx: &mut Context<Self, Self::Reply>) {
1187 // An empty upsert/deletion set: this is a re-broadcast of the unchanged peer set, not a
1188 // delta. Subscribers recompute their routes/filters against the current peers and the
1189 // (just-updated) runtime preferences (exit-node selector, accept-routes flag).
1190 if let Err(e) = self
1191 .env
1192 .publish(Arc::new(PeerState {
1193 upserts: HashSet::default(),
1194 deletions: HashSet::default(),
1195 peers: Arc::new(self.peer_db.clone()),
1196 silent_disco: self.silent_disco(),
1197 }))
1198 .await
1199 {
1200 tracing::error!(error = %e, "re-publishing peer state after a runtime preference change");
1201 }
1202 }
1203}
1204
1205impl PeerTracker {
1206 /// Learn a peer's disco key from a TSMP disco-key advertisement, returning whether the
1207 /// advertisement was applied.
1208 ///
1209 /// Go [`magicsock.Conn.HandleDiscoKeyAdvertisement`], reduced to the state this fork keeps:
1210 /// Go stores the learned key on the magicsock endpoint and re-keys its peer map, whereas here
1211 /// the peer db's `disco_key` (and its disco index) *is* the live lookup every direct-path
1212 /// consumer reads. The key is recorded in the peer's [`EndpointDisco`] TSMP slot — never on top
1213 /// of control's — and the peer db then carries whichever of the two is active, so the next
1214 /// netmap cannot silently undo it ([`upsert_from_control`](Self::upsert_from_control)).
1215 ///
1216 /// The three refusals are Go's, in Go's order:
1217 ///
1218 /// 1. **A zero key is never learned.** Go checks it twice — `tstun` publishes only
1219 /// `if !Key.IsZero()`, and `HandleDiscoKeyAdvertisement` rejects it again. The dataplane
1220 /// already dropped it here too; this is the second check, kept because the cost of getting
1221 /// it wrong is a peer bound to an unusable key.
1222 /// 2. **An unknown peer is ignored** (Go: "endpoint not found for node"). An advertisement
1223 /// never creates a peer — only control does — so one that arrives before or after the
1224 /// peer's netmap entry is a no-op, exactly like a `PeersChangedPatch` for an unknown node.
1225 /// 3. **An unchanged key is a no-op**, so a peer re-advertising the key we already hold costs
1226 /// no upsert and no republish (Go counts this as
1227 /// `magicsock_tsmp_disco_key_advertisement_unchanged` and returns). "Unchanged" is measured
1228 /// against the **TSMP-learned** key (Go compares `epDisco.keyFromTSMP()`), NOT against the
1229 /// effective one: an advertisement that merely restates what control already told us is new
1230 /// information — it is the peer itself confirming the key — so it is recorded as the active
1231 /// TSMP key and survives control later dropping or contradicting it.
1232 ///
1233 /// The tailnet-lock gate is deliberately *not* re-run: unlike a `PeersChangedPatch`, an
1234 /// advertisement cannot touch the node key or its TKA signature — only the disco key — so the
1235 /// peer-trust decision that admitted this node is unchanged by definition.
1236 ///
1237 /// [`magicsock.Conn.HandleDiscoKeyAdvertisement`]: https://github.com/tailscale/tailscale/blob/49e148c4a30b4f8098f69468fd27a7021d85ea02/wgengine/magicsock/magicsock.go
1238 fn learn_disco_key(&mut self, peer: PeerId, key: DiscoPublicKey) -> bool {
1239 if disco_key_is_zero(&key) {
1240 tracing::debug!(?peer, "TSMP-advertised disco key is the zero key; ignoring");
1241 return false;
1242 }
1243
1244 let Some((_id, existing)) = self.peer_db.get(&peer) else {
1245 tracing::debug!(
1246 ?peer,
1247 "TSMP disco-key advertisement for unknown peer; ignoring"
1248 );
1249 return false;
1250 };
1251
1252 let node_key = existing.node_key;
1253 if self
1254 .endpoint_disco
1255 .get(&node_key)
1256 .and_then(EndpointDisco::key_from_tsmp)
1257 == Some(key)
1258 {
1259 tracing::trace!(?peer, "TSMP-advertised disco key is unchanged");
1260 return false;
1261 }
1262
1263 let node = existing.clone();
1264 let disco = self.endpoint_disco.entry(node_key).or_default();
1265 disco.update_from_tsmp(Some(key));
1266 let disco = *disco;
1267 self.store_disco(&node, disco);
1268
1269 tracing::info!(
1270 ?peer,
1271 stable_id = ?node.stable_id,
1272 %key,
1273 "learned peer disco key from a TSMP advertisement"
1274 );
1275
1276 true
1277 }
1278
1279 /// Write a peer's resolved disco state onto the peer db.
1280 ///
1281 /// The node lands carrying the **effective** key ([`EndpointDisco::key`]), which is what the
1282 /// disco index — and so every *send* path — resolves against, and the peer's other known key
1283 /// (if any) is registered as its inactive ingress key so a frame arriving under it still
1284 /// attributes to this peer ([`PeerDb::peer_by_known_disco_key`]).
1285 ///
1286 /// Every disco-key writer goes through here — control, a TSMP advertisement, and an
1287 /// active-slot switch on receive — so the two cannot drift apart on which key is which.
1288 fn store_disco(&mut self, node: &Node, disco: EndpointDisco) -> PeerId {
1289 let effective = disco.key();
1290
1291 let id = if effective == node.disco_key {
1292 self.peer_db.upsert(node)
1293 } else {
1294 let mut node = node.clone();
1295 node.disco_key = effective;
1296 self.peer_db.upsert(&node)
1297 };
1298
1299 self.peer_db
1300 .set_inactive_disco_key(id, disco.inactive_key());
1301
1302 id
1303 }
1304
1305 /// Apply the sender key of an inbound disco frame to this peer's two-slot disco state — the
1306 /// `ts_runtime` half of Go [`endpoint.checkAndUpdateDiscoKey`].
1307 ///
1308 /// A peer mid-rotation keeps sending disco under the key it has not yet switched away from.
1309 /// Upstream accepts either of the two keys it knows for the peer and, when the one received is
1310 /// the currently-inactive one, makes it active: receiving under a key is proof of what the peer
1311 /// is using, and is stronger evidence than what control last said. Without this a rotation
1312 /// costs the peer its direct path until control catches up or the peer re-advertises.
1313 ///
1314 /// Returns whether the active key changed, so the caller can republish — which is how the
1315 /// direct manager learns to invalidate the trusted path built under the old key (Go's
1316 /// `changedActiveDiscoLocked`, reached here through the same snapshot diff every other
1317 /// disco-key transition uses).
1318 ///
1319 /// The refusals, all of which leave the peer db untouched:
1320 ///
1321 /// 1. **An unknown peer**, exactly as for a TSMP advertisement.
1322 /// 2. **A peer with no disco key material at all** (Go: `epDisco == nil` ⇒ `false`).
1323 /// 3. **A key belonging to neither slot.** This is the one that carries the security value:
1324 /// a peer must not be able to move itself onto a key nobody told us about, so a third key
1325 /// is refused even though the frame that carried it opened correctly.
1326 ///
1327 /// [`endpoint.checkAndUpdateDiscoKey`]: https://github.com/tailscale/tailscale/blob/9ea7cba44591e0cd840c6c94d23274dd222059bf/wgengine/magicsock/endpoint.go
1328 fn observe_disco_key(&mut self, peer: PeerId, key: DiscoPublicKey) -> bool {
1329 let Some((_id, existing)) = self.peer_db.get(&peer) else {
1330 tracing::debug!(?peer, "disco received for an unknown peer; ignoring");
1331 return false;
1332 };
1333
1334 let node = existing.clone();
1335 let Some(disco) = self.endpoint_disco.get_mut(&node.node_key) else {
1336 // Go's `epDisco == nil`: the peer has no key from either source, so there is nothing
1337 // this key could match and nothing to switch to.
1338 tracing::debug!(
1339 ?peer,
1340 "disco received for a peer with no known disco key; ignoring"
1341 );
1342 return false;
1343 };
1344
1345 let Some(changed) = disco.check_and_update(key) else {
1346 tracing::debug!(
1347 ?peer,
1348 %key,
1349 "refusing disco under a key that is neither of the peer's known disco keys"
1350 );
1351 return false;
1352 };
1353
1354 if !changed {
1355 return false;
1356 }
1357
1358 let disco = *disco;
1359 self.store_disco(&node, disco);
1360
1361 tracing::info!(
1362 ?peer,
1363 stable_id = ?node.stable_id,
1364 %key,
1365 "peer is sending disco under its other known key; making that key active"
1366 );
1367
1368 true
1369 }
1370
1371 /// Upsert a control-sourced [`Node`] into the peer db, resolving its disco key against anything
1372 /// this peer has told us over TSMP first.
1373 ///
1374 /// Every node built from control goes through here — `Full`, `Delta { upsert }`, and a
1375 /// `PeersChangedPatch` — so the three cannot diverge on which of the two keys wins. This is the
1376 /// disco half of Go [`endpoint.updateFromNode`]: control's key is written through
1377 /// [`EndpointDisco::update_from_control`] **only when it differs from what control last said**
1378 /// (Go's `if discoKey != n.DiscoKey()` guard, which compares `keyFromControl()`, never the
1379 /// effective key). So a netmap that merely restates the key control already sent leaves an
1380 /// active TSMP key alone — which is the entire point of the advertisement, whose motivating case
1381 /// is a peer whose key control has not caught up with. Control genuinely changing its mind is
1382 /// *recorded* in control's slot, but it does not take the active slot back from a TSMP-learned
1383 /// key: upstream switches back only when disco is received under control's key
1384 /// (`endpoint.checkAndUpdateDiscoKey`). See [`EndpointDisco::update_from_control`].
1385 ///
1386 /// The node lands in the db carrying the *effective* key ([`EndpointDisco::key`]), so the disco
1387 /// index and every send path resolve against the key we would actually send to; the other known
1388 /// key is registered for ingress attribution ([`store_disco`](Self::store_disco)).
1389 ///
1390 /// [`endpoint.updateFromNode`]: https://github.com/tailscale/tailscale/blob/49e148c4a30b4f8098f69468fd27a7021d85ea02/wgengine/magicsock/endpoint.go
1391 fn upsert_from_control(&mut self, node: &Node, now: chrono::DateTime<chrono::Utc>) -> PeerId {
1392 // The expiry pass, at the one site every peer install funnels through — Go
1393 // `flagExpiredPeers`, which runs over the whole netmap on the way in. A peer whose key
1394 // expiry has passed (judged against CONTROL's clock) is rewritten, never dropped: it keeps
1395 // its identity so `whois`, `status` and a peerAPI dial can all say *why* it is unreachable,
1396 // but it loses its endpoints, its home DERP and its usable node key. `None` is the ordinary
1397 // case — no transition — and costs no clone.
1398 let flagged = self.expiry.flag_expired_peer(node, now);
1399 // Log the transition, not the rewrite: control restates an expired peer unflagged on every
1400 // full netmap, so without this the line (and the reader's alarm) would repeat forever.
1401 if let Some(flagged) = flagged.as_ref().filter(|f| f.first_transition) {
1402 if flagged.peer.expired {
1403 tracing::info!(
1404 stable_id = ?flagged.peer.stable_id,
1405 "peer's node key has expired; clearing its endpoints and DERP home and \
1406 breaking its node key"
1407 );
1408 } else {
1409 tracing::info!(
1410 stable_id = ?flagged.peer.stable_id,
1411 "peer's node-key expiry was extended; restoring its node key"
1412 );
1413 }
1414 }
1415 let node = flagged.as_ref().map_or(node, |flagged| &flagged.peer);
1416
1417 let node_key = node.node_key;
1418 let from_control = disco_key_from_control(node.disco_key);
1419
1420 let disco = self.endpoint_disco.entry(node_key).or_default();
1421 if disco.key_from_control() != from_control {
1422 disco.update_from_control(from_control);
1423 }
1424 let disco = *disco;
1425
1426 // No key material from either source: Go nils the endpoint's `disco` pointer, so a peer
1427 // that has never had a disco key costs us no entry either.
1428 if disco.is_empty() {
1429 self.endpoint_disco.remove(&node_key);
1430 }
1431
1432 self.store_disco(node, disco)
1433 }
1434
1435 /// The disco key control last gave us for `node_key` — Go `endpointDisco.keyFromControl()`.
1436 fn control_disco_key(&self, node_key: &NodePublicKey) -> Option<DiscoPublicKey> {
1437 self.endpoint_disco
1438 .get(node_key)
1439 .and_then(EndpointDisco::key_from_control)
1440 }
1441
1442 /// Drop [`EndpointDisco`] state for node keys the peer db no longer holds.
1443 ///
1444 /// Go gets this for free: the two keys live on the magicsock `endpoint`, which the peer map keys
1445 /// by node key and deletes when the peer leaves the netmap — and a peer that rotates its node
1446 /// key gets a brand-new endpoint, so a TSMP-learned key is not carried across a rotation. Here
1447 /// the state is a side table, so every control update prunes it to get the same lifetime.
1448 fn prune_endpoint_disco(&mut self) {
1449 if self.endpoint_disco.is_empty() {
1450 return;
1451 }
1452
1453 let peers = &self.peer_db;
1454 self.endpoint_disco
1455 .retain(|node_key, _| peers.has(node_key).is_some());
1456 }
1457
1458 /// Apply a single [`PeerUpdate`](ts_control::PeerUpdate) to the peer db, enforcing the
1459 /// Tailnet-Lock peer-trust chokepoint ([`tka_admits`](Self::tka_admits)) at every upsert site.
1460 ///
1461 /// This is the **single source of truth** for the peer-trust enforcement loop: the actor's
1462 /// netmap [`handle`](Message::handle) calls it, and so do the TKA enforcement tests, so the two
1463 /// real upsert sites (`Full` and `Delta { upsert }`) cannot diverge from what is tested.
1464 ///
1465 /// `now` is the local wall clock the expiry pass in
1466 /// [`upsert_from_control`](Self::upsert_from_control) judges against (after correction for
1467 /// control's clock); it is threaded in rather than read per peer so one netmap is evaluated at
1468 /// one instant.
1469 ///
1470 /// Returns `(upserts, deletions)` — the [`PeerId`]s touched — for downstream bookkeeping.
1471 fn apply_peer_update(
1472 &mut self,
1473 peer_update: &ts_control::PeerUpdate,
1474 now: chrono::DateTime<chrono::Utc>,
1475 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
1476 let mut upserts = HashSet::default();
1477 let mut deletions = HashSet::default();
1478
1479 match peer_update {
1480 ts_control::PeerUpdate::Full(new_nodes) => {
1481 tracing::trace!("full peer update");
1482
1483 // Borrow the authority ONCE for the whole batch and verify each peer EXACTLY once
1484 // (Go runs `tkaFilterNetmapLocked` once over the assembled netmap; an earlier draft
1485 // verified every peer twice — once for `retained_ids`, once in the upsert loop —
1486 // doubling the ed25519 cost on the hot resync path). `tka_keep_verdicts` is that one
1487 // pass — per-peer signature verdict AND the cross-peer rotation filter — and is
1488 // shared verbatim with `tka_reevaluate_peer_db`, so the netmap path and the
1489 // authority-install path cannot drift apart on what "admitted" means.
1490 //
1491 // The result is a per-NODE keep vector (not a stable_id set), which drives both the
1492 // `retain` (evict revoked peers, keyed by stable_id) and the upsert loop. Judging
1493 // each node by its own verdict means a node whose signature fails is never admitted
1494 // on the strength of a different node that happens to share its stable_id.
1495 //
1496 // Revocation evicts: a peer re-included with a now-invalid/missing signature under an
1497 // active authority fails its verdict, so it is excluded from `retained_ids` and
1498 // `retain` drops the stale (previously-admitted) entry. With no authority the snapshot
1499 // is `None`, so every node passes — byte-for-byte the pre-TKA behavior (no regression).
1500 let authority = self.tka_authority_snapshot();
1501 let node_refs = new_nodes.iter().collect::<Vec<&Node>>();
1502 let keep = Self::tka_keep_verdicts(authority.as_deref(), &node_refs);
1503
1504 // `retained_ids` is the set of stable_ids that survive (drives `retain` to evict the
1505 // rest). It must agree with what the upsert loop below will leave in the db. Control
1506 // should never send two distinct nodes with the same `stable_id` in one `Full`, but if
1507 // it does, `peer_db.upsert` is last-writer-wins on `stable_id`, so the db ends holding
1508 // the LAST kept node for that id. Build `retained_ids` from kept nodes only — a
1509 // stable_id is retained iff at least one of its (possibly duplicate) nodes is kept, so
1510 // the upsert loop's last-kept node lands and `retain` never evicts a just-upserted id.
1511 let retained_ids = new_nodes
1512 .iter()
1513 .zip(keep.iter().copied())
1514 .filter(|(_, k)| *k)
1515 .map(|(node, _)| &node.stable_id)
1516 .collect::<HashSet<_>>();
1517
1518 // Isolation diagnostic: an ACTIVE lock that authorized none of the offered peers
1519 // leaves this node with no peers — surface it loudly so a self-lockout (vs an attack)
1520 // is diagnosable. `authority.is_some()` means a real keyed lock (the empty-keyset
1521 // brick-guard admits-all, so it never reaches here with zero retained).
1522 if authority.is_some() && !new_nodes.is_empty() && retained_ids.is_empty() {
1523 tracing::error!(
1524 offered = new_nodes.len(),
1525 "TKA: active lock authorized ZERO of the offered peers; node is isolated \
1526 (verify the lock state, or disable tailnet lock to recover)"
1527 );
1528 }
1529
1530 self.peer_db.retain(|id, peer| {
1531 let retain = retained_ids.contains(&peer.stable_id);
1532
1533 if !retain {
1534 deletions.insert(id);
1535 }
1536
1537 retain
1538 });
1539
1540 for (node, k) in new_nodes.iter().zip(keep.iter().copied()) {
1541 if !k {
1542 continue; // fail-CLOSED: rejected by tailnet lock or rotation-obsolete (above)
1543 }
1544 let peer_id = self.upsert_from_control(node, now);
1545 upserts.insert(peer_id);
1546 }
1547 }
1548
1549 ts_control::PeerUpdate::Delta { remove, upsert } => {
1550 tracing::trace!("delta peer update");
1551
1552 for peer in upsert {
1553 if !self.tka_admits(peer) {
1554 // fail-CLOSED: do not upsert a peer rejected by tailnet lock. If the peer is
1555 // ALREADY in the db (a delta re-upserting an existing peer whose signature is
1556 // now invalid — e.g. revoked between syncs), evict the stale entry rather than
1557 // leaving an unverified peer admitted; Go re-filters the whole netmap each map
1558 // response, so a now-unsigned peer would not survive there either.
1559 if let Some((id, _)) = self.peer_db.remove(&peer.stable_id) {
1560 tracing::warn!(
1561 stable_id = ?peer.stable_id,
1562 "TKA: delta re-upsert rejected; evicting now-unauthorized peer"
1563 );
1564 deletions.insert(id);
1565 }
1566 continue;
1567 }
1568 let id = self.upsert_from_control(peer, now);
1569
1570 upserts.insert(id);
1571 }
1572
1573 for peer in remove {
1574 let Some((id, _node)) = self.peer_db.remove(peer) else {
1575 // A benign, expected race: the peer may already be gone (dropped in a prior
1576 // `Full`, or fail-closed by TKA — whose now-"unknown" ids commonly reappear in
1577 // a trailing `peers_removed`). Go treats an unknown removal as a no-op; log at
1578 // debug, not error, to avoid false-alarm noise on a healthy node (matches the
1579 // unknown-node handling in `apply_peer_patches`).
1580 tracing::debug!(
1581 control_node_id = peer,
1582 "removed peer was unknown; ignoring"
1583 );
1584 continue;
1585 };
1586
1587 deletions.insert(id);
1588 }
1589 }
1590 }
1591
1592 self.prune_endpoint_disco();
1593
1594 (upserts, deletions)
1595 }
1596
1597 /// Re-run the Tailnet-Lock filter over the peers **already in the peer db**, evicting the ones
1598 /// the current authority does not admit. Returns the evicted [`PeerId`]s (empty when nothing
1599 /// changed, which is the overwhelmingly common case).
1600 ///
1601 /// # Why this exists (a Go-ordering gap, not an extra feature)
1602 /// Go filters the very netmap that announced the lock: `SetControlClientStatus`
1603 /// (`ipn/ipnlocal/local.go`, v1.100.0) calls `tkaSyncIfNeeded` and then, a few lines later,
1604 /// `tkaFilterNetmapLocked(st.NetMap)` — synchronously, on the same `st.NetMap`, in one pass. So
1605 /// the peers announced alongside `TKAEnabled` are checked by the authority that sync just built.
1606 ///
1607 /// Here the sync is a spawned task (`control_runner`'s `maybe_sync_tka`), so the ordering is
1608 /// inverted: the netmap that carried the `TkaStatus` reaches the peer db *before* the authority
1609 /// exists, and is admitted with enforcement inactive. Without this pass those peers stay
1610 /// admitted — unauthorized ones included — until control happens to send another `Full`, which on
1611 /// a steady map poll may be never. That is the whole initial peer set escaping a lock the node
1612 /// really did sync, so this runs the moment the authority is installed ([`TkaAuthorityChanged`])
1613 /// and brings the db back in line.
1614 ///
1615 /// No authority (nothing synced yet, or the lock was disabled) ⇒ no eviction: enforcement is
1616 /// inactive and every peer is admitted, exactly Go's `b.tka == nil` early return. A peer dropped
1617 /// while the lock was active is **not** resurrected by a later disable — the db no longer holds
1618 /// it and this fork keeps no shadow copy of filtered nodes (Go's `b.tka.filtered`); it returns on
1619 /// the next netmap that re-includes it. That is the safe direction: more restrictive, and
1620 /// connectivity-only.
1621 fn tka_reevaluate_peer_db(&mut self) -> HashSet<PeerId> {
1622 let Some(authority) = self.tka_authority_snapshot() else {
1623 return HashSet::default();
1624 };
1625
1626 // Verdicts first, under an immutable borrow of the db; the eviction below needs `&mut`.
1627 let evicted: HashSet<PeerId> = {
1628 let entries = self
1629 .peer_db
1630 .peers()
1631 .iter()
1632 // A peer this node already flagged expired is skipped: its node key is one WE
1633 // broke (`ExpiryManager::flag_expired_peer`), so re-verifying control's signature
1634 // against it would be checking our own mutation, and the peer would always be
1635 // evicted. It was admitted by the lock when it was installed, against the real key
1636 // control sent, and it has had no usable key since — so keeping the row costs no
1637 // trust and preserves the thing expiry flagging exists for: an expired peer is
1638 // FLAGGED, not dropped, so a caller can say why it is unreachable.
1639 .filter(|(_, node)| !node.expired)
1640 .map(|(id, node)| (*id, node))
1641 .collect::<Vec<(PeerId, &Node)>>();
1642 let nodes = entries
1643 .iter()
1644 .map(|(_, node)| *node)
1645 .collect::<Vec<&Node>>();
1646 let keep = Self::tka_keep_verdicts(Some(&authority), &nodes);
1647 entries
1648 .iter()
1649 .zip(keep)
1650 .filter_map(|((id, _), keep)| (!keep).then_some(*id))
1651 .collect()
1652 };
1653
1654 if evicted.is_empty() {
1655 return evicted;
1656 }
1657
1658 tracing::warn!(
1659 n_evicted = evicted.len(),
1660 peer_count = self.peer_db.peers().len(),
1661 "TKA: re-filtered the peer db against the newly installed lock authority; evicted \
1662 already-admitted peers"
1663 );
1664 self.peer_db.retain(|id, _| !evicted.contains(&id));
1665 self.prune_endpoint_disco();
1666 evicted
1667 }
1668
1669 /// Re-run the expiry pass over the peers **already in the peer db**, returning the [`PeerId`]s
1670 /// whose node changed (empty when nothing expired, the overwhelmingly common case).
1671 ///
1672 /// The timer's counterpart to the pass [`upsert_from_control`](Self::upsert_from_control) runs
1673 /// on the way in: that one catches a peer that was already expired when control handed it to
1674 /// us, this one catches a peer that expires while we sit on the same netmap.
1675 ///
1676 /// Only peers that actually transition are cloned and re-installed — the pass returns `None`
1677 /// for the rest — so a timer firing over a large peer set costs one walk and a handful of
1678 /// upserts. Re-installing goes through the ordinary upsert path so the node-key index follows
1679 /// the peer's now-broken key; the pass there is a no-op on an already-flagged peer.
1680 fn reevaluate_expiry(&mut self, now: chrono::DateTime<chrono::Utc>) -> HashSet<PeerId> {
1681 let flagged = self
1682 .peer_db
1683 .peers()
1684 .values()
1685 .filter_map(|peer| self.expiry.flag_expired_peer(peer, now))
1686 .collect::<Vec<ts_control::FlaggedPeer>>();
1687
1688 if flagged.is_empty() {
1689 return HashSet::default();
1690 }
1691
1692 tracing::info!(
1693 n = flagged.len(),
1694 "netmap expiry timer fired; peers whose node keys expired between netmaps"
1695 );
1696
1697 let mut upserts = HashSet::default();
1698 for flagged in &flagged {
1699 // `upsert_from_control` re-runs the pass, which is a no-op on the peer it just
1700 // rewrote — so the log line belongs here, where the transition is known.
1701 if flagged.first_transition && flagged.peer.expired {
1702 tracing::info!(
1703 stable_id = ?flagged.peer.stable_id,
1704 "peer's node key has expired; clearing its endpoints and DERP home and \
1705 breaking its node key"
1706 );
1707 }
1708 upserts.insert(self.upsert_from_control(&flagged.peer, now));
1709 }
1710 self.prune_endpoint_disco();
1711
1712 upserts
1713 }
1714
1715 /// Whether control has asked this node to stop heartbeating its peers — the `silent-disco`
1716 /// attribute on the self node, published with every [`PeerState`] snapshot.
1717 ///
1718 /// Read off the self node this tracker last saw, which is the one the current response
1719 /// refreshed, so an attribute control grants takes effect on the netmap that granted it (the
1720 /// timing `delta_updates_disabled` already has here). No self node yet ⇒ `false` ⇒ the existing
1721 /// heartbeat cadence, which is the safe default: the cost of heartbeating a node control wanted
1722 /// quiet is a ping every 2s, while the cost of going quiet on a guess is a direct path falling
1723 /// back to DERP.
1724 fn silent_disco(&self) -> bool {
1725 self.self_node.as_ref().is_some_and(Node::silent_disco)
1726 }
1727
1728 /// Stop the armed expiry timer and arm a new one for the soonest future key expiry across the
1729 /// peer db and the self node — Go `setControlClientStatusLocked`'s `nmExpiryTimer` block.
1730 ///
1731 /// No future expiry (every peer tagged or already flagged, and no self expiry) leaves no timer
1732 /// armed; the next netmap re-decides. The delay carries upstream's
1733 /// [`EXPIRY_TIMER_SLACK_SECS`](ts_control::EXPIRY_TIMER_SLACK_SECS) of slack so the key is
1734 /// unambiguously past its expiry by the time the pass runs.
1735 ///
1736 /// The old timer is **aborted**, which is this fork's version of Go's `numClientStatusCalls`
1737 /// generation check: a task that has already been dropped cannot deliver a stale wake-up. The
1738 /// spawned task holds only a `WeakActorRef`, so it can never keep the tracker's mailbox alive
1739 /// past shutdown.
1740 fn rearm_expiry_timer(&mut self, now: chrono::DateTime<chrono::Utc>, slf: &ActorRef<Self>) {
1741 if let Some(timer) = self.expiry_timer.take() {
1742 timer.abort();
1743 }
1744
1745 let Some(next) = self.expiry.next_peer_expiry(
1746 self.peer_db.peers().values(),
1747 self.self_node.as_ref(),
1748 now,
1749 ) else {
1750 return;
1751 };
1752
1753 let delay = (next - now) + chrono::TimeDelta::seconds(ts_control::EXPIRY_TIMER_SLACK_SECS);
1754 // `next` is never before `now` (the expiry manager floors it), so the conversion holds; a
1755 // negative delta would only mean "fire immediately", which is also the safe reading.
1756 let delay = delay.to_std().unwrap_or(std::time::Duration::ZERO);
1757
1758 tracing::debug!(
1759 delay_secs = delay.as_secs(),
1760 "arming the netmap expiry timer for the next node-key expiry"
1761 );
1762
1763 let notify = slf.downgrade();
1764 self.expiry_timer = Some(tokio::spawn(async move {
1765 tokio::time::sleep(delay).await;
1766 let Some(tracker) = notify.upgrade() else {
1767 return; // the peer tracker is gone; nothing left to re-evaluate
1768 };
1769 if let Err(e) = tracker.tell(ExpiryTimerFired).await {
1770 tracing::debug!(error = %e, "peer tracker stopped before the expiry timer fired");
1771 }
1772 }));
1773 }
1774
1775 /// Apply the response's `MapResponse.PeersChangedPatch` set, choosing the incremental path or
1776 /// the fall-back-to-full one according to control's `disable-delta-updates` node attribute.
1777 ///
1778 /// This is the port of the first statement of Go `control/controlclient/map.go`'s
1779 /// `tryHandleIncrementally` — `if ms.controlKnobs != nil &&
1780 /// ms.controlKnobs.DisableDeltaUpdates.Load() { return false }` — and of what returning `false`
1781 /// there means: the map session does not reject the response and does not drop the mutations it
1782 /// carries, it declines the incremental arm so the full netmap rebuild handles the *same*
1783 /// response. So a patch-only response under the attribute is still applied, as a full update.
1784 ///
1785 /// The attribute is read off the **self** node, which the netmap handler refreshes from this
1786 /// very response before it gets here, so an attribute control granted on this response takes
1787 /// effect on the response that granted it — the same timing the netmap cache's attribute read
1788 /// has. No self node yet (nothing has carried one) reads as absent ⇒ the incremental path.
1789 fn apply_peer_patch_set(
1790 &mut self,
1791 patches: &[ts_control::PeerChange],
1792 now: chrono::DateTime<chrono::Utc>,
1793 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
1794 if self
1795 .self_node
1796 .as_ref()
1797 .is_some_and(Node::delta_updates_disabled)
1798 {
1799 tracing::debug!(
1800 n = patches.len(),
1801 "control set disable-delta-updates; applying this response's peer patches as a \
1802 full netmap update"
1803 );
1804 return self.rebuild_netmap_with_patches(patches, now);
1805 }
1806
1807 self.apply_peer_patches(patches, now)
1808 }
1809
1810 /// The fall-back-to-full arm of [`apply_peer_patch_set`](Self::apply_peer_patch_set): fold the
1811 /// patches in, then re-install the **whole** retained netmap rather than the patched nodes
1812 /// alone.
1813 ///
1814 /// Go reaches the same place by a different route because its map session keeps its own peer
1815 /// store: `HandleNonKeepAliveMapResponse` absorbs `PeersChangedPatch` into that store
1816 /// (`updateStateFromResponse`) *before* it decides how to hand the result downstream, then —
1817 /// when `tryHandleIncrementally` declines — rebuilds the netmap from the store (`ms.netmap()`,
1818 /// which re-runs `flagExpiredPeers` over every peer) and installs it whole with
1819 /// `UpdateFullNetmap`. Here the peer db *is* that store, so step one is the ordinary patch fold
1820 /// and step two is re-installing every peer through the same control-sourced upsert path, which
1821 /// is what re-runs the expiry pass over the whole netmap and puts every peer in the published
1822 /// upsert set. Upstream names the cost itself — "lots of garbage & work downstream" — and it is
1823 /// the point of the escape hatch, not a side effect of it.
1824 ///
1825 /// Nothing here evicts a peer the incremental path would have kept: the only trust gate is the
1826 /// per-patched-node one the fold already runs. Re-verifying the *whole* db against tailnet lock
1827 /// (Go's full arm re-runs `tkaFilterNetmapLocked`) is deliberately NOT done, because the nodes
1828 /// in the db are this node's own copies rather than control's pristine ones — a peer this node
1829 /// flagged expired carries a node key we broke ourselves, so its signature can no longer verify
1830 /// and re-filtering would evict it. That is the same carve-out, for the same reason, that
1831 /// [`tka_reevaluate_peer_db`](Self::tka_reevaluate_peer_db) already makes.
1832 fn rebuild_netmap_with_patches(
1833 &mut self,
1834 patches: &[ts_control::PeerChange],
1835 now: chrono::DateTime<chrono::Utc>,
1836 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
1837 let (mut upserts, deletions) = self.apply_peer_patches(patches, now);
1838
1839 // Re-install the netmap the fold above just produced, whole. Cloning first keeps the db
1840 // borrow off the upsert loop; it is the "garbage" half of upstream's own description.
1841 let netmap: Vec<Node> = self.peer_db.peers().values().cloned().collect();
1842 for mut node in netmap {
1843 // The db carries the EFFECTIVE disco key, which may have been learned over TSMP, so
1844 // restate what CONTROL last said before re-installing. Without this, re-installing a
1845 // peer whose active key came from a TSMP advertisement would hand that key back as if
1846 // control had sent it, and `upsert_from_control` would write it into control's slot —
1847 // losing the key control actually gave us. Same reasoning as the patch fold's own
1848 // restatement, applied to every peer because every peer is re-installed here.
1849 node.disco_key = self.control_disco_key(&node.node_key);
1850 upserts.insert(self.upsert_from_control(&node, now));
1851 }
1852
1853 (upserts, deletions)
1854 }
1855
1856 /// Apply field-level peer patches (`MapResponse.PeersChangedPatch`), returning the upserted /
1857 /// deleted [`PeerId`]s. The incremental arm of
1858 /// [`apply_peer_patch_set`](Self::apply_peer_patch_set), and the fold both arms share.
1859 ///
1860 /// This is a SEPARATE channel from [`apply_peer_update`](Self::apply_peer_update): Go's
1861 /// `controlclient` applies the whole-node `Peers*` set first and then `PeersChangedPatch`, so a
1862 /// response that carries both has the peer set applied first (by the caller) and these patches
1863 /// applied second, on top of the freshly-synced nodes. A patch only mutates a peer already in the
1864 /// netmap; an unknown node id is ignored (the wire contract — a patch never creates a node).
1865 fn apply_peer_patches(
1866 &mut self,
1867 patches: &[ts_control::PeerChange],
1868 now: chrono::DateTime<chrono::Utc>,
1869 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
1870 let mut upserts = HashSet::default();
1871 let mut deletions = HashSet::default();
1872
1873 tracing::trace!(n = patches.len(), "peer patch update");
1874
1875 for patch in patches {
1876 // Clone the current node, apply the present fields, and re-upsert through the same path
1877 // as a delta so indexes/routes stay consistent.
1878 let Some((_id, existing)) = self.peer_db.get(&patch.id) else {
1879 tracing::debug!(
1880 control_node_id = patch.id,
1881 "peer patch for unknown node; ignoring"
1882 );
1883 continue;
1884 };
1885
1886 let mut node = existing.clone();
1887 if let Some(endpoints) = &patch.underlay_addresses {
1888 node.underlay_addresses = endpoints.clone();
1889 }
1890 if let Some(derp) = patch.derp_region {
1891 node.derp_region = Some(derp);
1892 }
1893 if let Some(cap) = patch.cap {
1894 node.cap = cap;
1895 }
1896 if let Some(cap_map) = &patch.cap_map {
1897 node.cap_map = cap_map.clone();
1898 }
1899 // The db entry carries the EFFECTIVE disco key, which may have been learned over TSMP,
1900 // so restate what CONTROL last said before folding the patch in. Otherwise a patch that
1901 // says nothing about the disco key would hand a TSMP-learned key back as if control had
1902 // sent it, and `upsert_from_control` would write it into control's slot — losing the key
1903 // control actually gave us, on a patch that never mentioned the disco key at all.
1904 node.disco_key = self.control_disco_key(&node.node_key);
1905 if let Some(disco_key) = patch.disco_key {
1906 node.disco_key = Some(disco_key);
1907 }
1908 if let Some(expiry) = patch.node_key_expiry {
1909 node.node_key_expiry = Some(expiry);
1910 }
1911 // Online/last-seen liveness deltas (`PeerChange.Online`/`LastSeen`) — the dominant
1912 // channel by which peer online transitions arrive mid-session. A patch only ever *sets*
1913 // a value (never patches back to unknown), so apply when present.
1914 if let Some(online) = patch.online {
1915 node.online = Some(online);
1916 }
1917 if let Some(last_seen) = patch.last_seen {
1918 node.last_seen = Some(last_seen);
1919 }
1920 // Key rotation: a patch may swap the node key (and its TKA signature). Apply both
1921 // together so the trust gate below verifies the new signature against the new key, never
1922 // a mismatched pair.
1923 if let Some(node_key) = patch.node_key {
1924 node.node_key = node_key;
1925 // Control restated the key, so this node's own break of it (if the peer had
1926 // expired) no longer applies: clear the client-set flag and let the expiry pass in
1927 // `upsert_from_control` decide again against the (possibly also patched) expiry.
1928 // Go gets this for free — it patches a pristine node and re-runs `flagExpiredPeers`
1929 // over the result.
1930 node.expired = false;
1931 }
1932 if let Some(sig) = &patch.key_signature {
1933 node.key_signature = sig.clone();
1934 }
1935
1936 // Re-run the tailnet-lock gate on the patched node: a patch that rotates the key must
1937 // satisfy the active authority, exactly like a `Delta` upsert, or it would be a
1938 // trust-enforcement bypass. fail-CLOSED — if the patched node is no longer admitted,
1939 // evict it rather than keep the stale (now-unverified) entry.
1940 if !self.tka_admits(&node) {
1941 if let Some((id, _)) = self.peer_db.remove(&patch.id) {
1942 tracing::warn!(
1943 control_node_id = patch.id,
1944 "peer patch rejected by tailnet lock; evicting peer"
1945 );
1946 deletions.insert(id);
1947 }
1948 continue;
1949 }
1950
1951 let id = self.upsert_from_control(&node, now);
1952 upserts.insert(id);
1953 }
1954
1955 self.prune_endpoint_disco();
1956
1957 (upserts, deletions)
1958 }
1959
1960 /// Apply the standalone online/last-seen delta maps (`MapResponse.OnlineChange` /
1961 /// `PeerSeenChange`, channels C/D) onto the retained netmap. Returns `true` if any node was
1962 /// actually mutated (so the caller knows whether to re-publish).
1963 ///
1964 /// Mirrors Go `controlclient/map.go:updatePeersStateFromResponse` (the two channels are
1965 /// semantically DISTINCT and must not be conflated):
1966 /// - `OnlineChange` (channel C) is the sole driver of a peer's `online` flag (`mut.Online = v`).
1967 /// - `PeerSeenChange` (channel D) is the sole driver of `last_seen`: `true ⇒ LastSeen = now`,
1968 /// `false ⇒ LastSeen = nil` (cleared). It NEVER touches `online` — "not seen recently" is not
1969 /// the same as "offline", which only `OnlineChange` asserts.
1970 ///
1971 /// Each entry is keyed by control node id and applies to a peer already in the netmap; an unknown
1972 /// node id is ignored (these maps never create a node). `now` is the wall-clock timestamp for a
1973 /// `PeerSeenChange: true` (Go uses `clock.Now()`); the caller passes it so this stays a pure
1974 /// function of its inputs. Returns `true` if any node was actually mutated.
1975 fn apply_liveness_changes(
1976 &mut self,
1977 online_change: &std::collections::BTreeMap<ts_control::NodeId, bool>,
1978 peer_seen_change: &std::collections::BTreeMap<ts_control::NodeId, bool>,
1979 now: chrono::DateTime<chrono::Utc>,
1980 ) -> bool {
1981 let mut changed = false;
1982
1983 // Channel C — direct online flips (the only writer of `online`).
1984 for (&node_id, &online) in online_change {
1985 if let Some((_pid, existing)) = self.peer_db.get(&node_id)
1986 && existing.online != Some(online)
1987 {
1988 let mut node = existing.clone();
1989 node.online = Some(online);
1990 self.peer_db.upsert(&node);
1991 changed = true;
1992 }
1993 }
1994
1995 // Channel D — peer-seen flips (the only writer of `last_seen`; never touches `online`).
1996 // `true` ⇒ last-seen is now; `false` ⇒ last-seen cleared (Go map.go:820-830).
1997 for (&node_id, &seen) in peer_seen_change {
1998 let new_last_seen = if seen { Some(now) } else { None };
1999 if let Some((_pid, existing)) = self.peer_db.get(&node_id)
2000 && existing.last_seen != new_last_seen
2001 {
2002 let mut node = existing.clone();
2003 node.last_seen = new_last_seen;
2004 self.peer_db.upsert(&node);
2005 changed = true;
2006 }
2007 }
2008
2009 changed
2010 }
2011
2012 /// Test-only constructor: build a [`PeerTracker`] with a chosen initial TKA authority without
2013 /// going through the actor `on_start` path. Returns the tracker plus the **`watch::Sender`** for
2014 /// its enforcement-authority cell, so a test can drive the exact enable/disable transitions the
2015 /// control runner drives at runtime (`tx.send_replace(Some(..))` ⇒ enforce, `tx.send_replace(None)`
2016 /// ⇒ clear). The initial `Some` exercises the fail-closed chokepoint
2017 /// ([`tka_admits`](Self::tka_admits)); `None` is the no-lock admit-all path. The returned sender
2018 /// must be kept alive for the tracker to read updated values.
2019 #[cfg(test)]
2020 fn for_test(
2021 env: Env,
2022 tka_authority: Option<ts_tka::Authority>,
2023 ) -> (Self, watch::Sender<Option<Arc<ts_tka::Authority>>>) {
2024 let (peer_watch, _) = watch::channel(Vec::new());
2025 let (tka_tx, tka_rx) = watch::channel(tka_authority.map(Arc::new));
2026 let tracker = Self {
2027 peer_db: PeerDb::default(),
2028 seen_state_update: false,
2029 pending_requests: Vec::new(),
2030 peer_watch,
2031 user_profiles: HashMap::new(),
2032 endpoint_disco: HashMap::new(),
2033 tka_authority: tka_rx,
2034 expiry: ExpiryManager::new(),
2035 self_node: None,
2036 expiry_timer: None,
2037 env,
2038 };
2039 (tracker, tka_tx)
2040 }
2041
2042 fn service_pending_requests(&mut self) {
2043 if self.seen_state_update {
2044 return;
2045 }
2046
2047 self.seen_state_update = true;
2048
2049 if !self.pending_requests.is_empty() {
2050 tracing::debug!(
2051 n_pending = self.pending_requests.len(),
2052 "state update received, servicing pending requests"
2053 );
2054 }
2055
2056 for req in core::mem::take(&mut self.pending_requests) {
2057 match req {
2058 Pending::PeerByName(PeerByName { name }, reply) => {
2059 reply.send(self.peer_by_name_opt(&name).cloned());
2060 }
2061 Pending::TailnetIp(PeerByTailnetIp { ip }, reply) => {
2062 reply.send(self.peer_by_tailnet_ip_opt(ip).cloned());
2063 }
2064 Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, reply) => {
2065 reply.send(
2066 self.peer_db
2067 .get_route(ip.into())
2068 .map(|(_id, node)| node.clone())
2069 .collect(),
2070 );
2071 }
2072 Pending::Status(reply) => {
2073 reply.send(self.status_peers_with_ids());
2074 }
2075 Pending::WhoIs(Whois { addr }, reply) => {
2076 reply.send(self.whois_opt(addr));
2077 }
2078 }
2079 }
2080 }
2081}
2082
2083#[cfg(test)]
2084pub(crate) mod tka_tests {
2085 //! Tailnet-Lock (TKA) enforcement tests for the peer-trust chokepoint.
2086 //!
2087 //! These exercise [`PeerTracker::tka_admits`] and the `tka_admits ⇒ upsert` loop the netmap
2088 //! handler runs. The test [`ts_tka::Authority`] is built with [`ts_tka::Authority::from_state`]
2089 //! over a known Ed25519 trusted key, and the signed node-key signature CBOR is produced through
2090 //! `ts_tka`'s public `cbor` encoder + `aum_hash` (the exact same canonical bytes `ts_tka`'s own
2091 //! `direct_signature_verifies_end_to_end` test signs, with no new crypto vectors invented and no
2092 //! private `ts_tka` API used).
2093
2094 use ed25519_dalek::{Signer, SigningKey};
2095 use ts_control::{Node, StableNodeId, TailnetAddress};
2096 use ts_tka::{
2097 AumHash, Authority, Key, KeyKind, State,
2098 cbor::{self, Value},
2099 };
2100
2101 use super::*;
2102
2103 /// `SigKind::Direct` wire value (Go `SigKind`; `ts_tka::SigKind::Direct = 1`).
2104 const SIG_KIND_DIRECT: u64 = 1;
2105
2106 /// The 32-byte node key used across the signed-peer fixtures.
2107 const NODE_KEY_BYTES: [u8; 32] = [7u8; 32];
2108
2109 /// Build a real [`Env`] for the tracker. Only the bus/keys/shutdown plumbing matters here; the
2110 /// TKA gate reads neither, so the forwarding preferences are all benign defaults.
2111 pub(super) fn test_env() -> Env {
2112 let (_shutdown_tx, shutdown_rx) = watch::channel(false);
2113 Env::new(
2114 ts_keys::NodeState::generate(),
2115 shutdown_rx,
2116 crate::env::ForwarderConfig {
2117 accept_routes: false,
2118 accept_dns: true,
2119 exit_node: None,
2120 forward_routes: Vec::new(),
2121 forward_tcp_ports: Vec::new(),
2122 forward_udp_ports: Vec::new(),
2123 forward_all_ports: false,
2124 forward_exit_egress: false,
2125 block_incoming: false,
2126 exit_proxy: None,
2127 exit_node_dns: None,
2128 peerapi_port: None,
2129 taildrop_dir: None,
2130 enable_ipv6: false,
2131 wireguard_listen_port: None,
2132 network_monitor: false,
2133 port_mapper: None,
2134 persistent_keepalive_interval: None,
2135 ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
2136 },
2137 )
2138 }
2139
2140 /// A minimal peer [`Node`] carrying `node_key` and the given `key_signature`.
2141 ///
2142 /// `pub(crate)` so the cold-start replay tests in `control_runner` build their peers the same way
2143 /// this module's TKA tests do — both run the same filter, and a second hand-rolled fixture could
2144 /// drift from it.
2145 pub(crate) fn peer_node(stable_id: &str, node_key: [u8; 32], key_signature: Vec<u8>) -> Node {
2146 Node {
2147 id: 1,
2148 stable_id: StableNodeId(stable_id.to_string()),
2149 hostname: stable_id.to_string(),
2150 user_id: 0,
2151 tailnet: Some("ts.net".to_string()),
2152 tags: Vec::new(),
2153 addresses: vec![
2154 "100.64.0.1/32".parse().unwrap(),
2155 "fd7a:115c:a1e0::1/128".parse().unwrap(),
2156 ],
2157 tailnet_address: TailnetAddress {
2158 ipv4: "100.64.0.1/32".parse().unwrap(),
2159 ipv6: "fd7a:115c:a1e0::1/128".parse().unwrap(),
2160 },
2161 node_key: node_key.into(),
2162 node_key_expiry: None,
2163 expired: false,
2164 online: None,
2165 last_seen: None,
2166 key_signature,
2167 machine_key: None,
2168 disco_key: None,
2169 accepted_routes: Vec::new(),
2170 underlay_addresses: Vec::new(),
2171 derp_region: None,
2172 cap: Default::default(),
2173 cap_map: Default::default(),
2174 peerapi_port: None,
2175 peerapi_dns_proxy: false,
2176 is_wireguard_only: false,
2177 exit_node_dns_resolvers: Vec::new(),
2178 peer_relay: false,
2179 ssh_host_keys: Vec::new(),
2180 service_vips: Default::default(),
2181 unsigned_peer_api_only: false,
2182 }
2183 }
2184
2185 /// Encode a `Direct` [`ts_tka::NodeKeySignature`] CBOR exactly as `ts_tka`'s private `to_cbor`
2186 /// does (int-map keys: 1=kind, 2=pubkey, 3=key_id, 4=signature; empty byte fields omitted),
2187 /// using only the crate's *public* `cbor` encoder. `signature` of `None` produces the
2188 /// signing-digest preimage (the `SigHash` form).
2189 fn direct_sig_cbor(node_key: &[u8], key_id: &[u8], signature: Option<&[u8]>) -> Vec<u8> {
2190 let mut pairs = alloc_pairs(node_key, key_id);
2191 if let Some(sig) = signature {
2192 pairs.push((4, Some(Value::Bytes(sig.to_vec()))));
2193 }
2194 cbor::int_map(pairs).to_vec()
2195 }
2196
2197 fn alloc_pairs(node_key: &[u8], key_id: &[u8]) -> Vec<(u64, Option<Value>)> {
2198 vec![
2199 (1, Some(Value::Uint(SIG_KIND_DIRECT))),
2200 (2, Some(Value::Bytes(node_key.to_vec()))),
2201 (3, Some(Value::Bytes(key_id.to_vec()))),
2202 ]
2203 }
2204
2205 /// Build a TKA [`Authority`] that trusts `signing.verifying_key()`, plus a valid `Direct`
2206 /// node-key signature CBOR authorizing [`NODE_KEY_BYTES`] under it.
2207 fn authority_and_valid_sig() -> (Authority, Vec<u8>) {
2208 // A fixed, known Ed25519 trusted key (mirrors ts_tka's own end-to-end test seed).
2209 let signing = SigningKey::from_bytes(&[42u8; 32]);
2210 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
2211
2212 let authority = Authority::from_state(
2213 AumHash([0; 32]),
2214 State {
2215 keys: vec![Key {
2216 kind: KeyKind::Ed25519,
2217 votes: 1,
2218 public: trusted_pub.clone(),
2219 }],
2220 },
2221 );
2222
2223 // SigHash preimage = canonical CBOR with the signature field omitted; sign its blake2s hash.
2224 let preimage = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, None);
2225 let sig_hash = ts_tka::aum_hash(&preimage).0;
2226 let signature = signing.sign(&sig_hash).to_bytes().to_vec();
2227
2228 let signed_cbor = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, Some(&signature));
2229 // Sanity: the authority accepts the signature we just built (same path the gate uses).
2230 assert!(
2231 authority
2232 .node_key_authorized(&NODE_KEY_BYTES, &signed_cbor)
2233 .is_ok()
2234 );
2235
2236 (authority, signed_cbor)
2237 }
2238
2239 #[tokio::test]
2240 async fn tka_inactive_upserts_all_peers() {
2241 // No authority ⇒ enforcement inactive ⇒ both a signed and an unsigned peer are admitted.
2242 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2243
2244 let signed = peer_node("signed", [1u8; 32], vec![0xde, 0xad, 0xbe, 0xef]);
2245 let unsigned = peer_node("unsigned", [2u8; 32], vec![]);
2246
2247 assert!(tracker.tka_admits(&signed));
2248 assert!(tracker.tka_admits(&unsigned));
2249
2250 tracker.peer_db.upsert(&signed);
2251 tracker.peer_db.upsert(&unsigned);
2252 assert_eq!(tracker.peer_db.peers().len(), 2);
2253 }
2254
2255 #[tokio::test]
2256 async fn tka_active_rejects_unsigned_peer() {
2257 // Authority present + peer presents no signature ⇒ rejected (fail-closed), not in peer_db.
2258 let (authority, _sig) = authority_and_valid_sig();
2259 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2260
2261 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
2262 assert!(!tracker.tka_admits(&unsigned));
2263
2264 // Mirror the handler's `if !tka_admits { continue }` loop.
2265 if tracker.tka_admits(&unsigned) {
2266 tracker.peer_db.upsert(&unsigned);
2267 }
2268 assert_eq!(tracker.peer_db.peers().len(), 0);
2269 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
2270 }
2271
2272 #[tokio::test]
2273 async fn tka_active_rejects_unsigned_peer_api_only_peer() {
2274 // `UnsignedPeerAPIOnly` buys NO admission exemption here: Go admits such a peer unsigned
2275 // under an active lock (peerAPI-only, no network access), this fork drops it like any other
2276 // unsigned peer. Pins the documented parity gap (`docs/PARITY_ROADMAP.md`, and the
2277 // `ts_control::Node::unsigned_peer_api_only` field docs) so implementing the carve-out has
2278 // to update the prose that promises no peerAPI access today.
2279 let (authority, _sig) = authority_and_valid_sig();
2280 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2281
2282 let mut peer_api_only = peer_node("peerapi-only", NODE_KEY_BYTES, vec![]);
2283 peer_api_only.unsigned_peer_api_only = true;
2284
2285 assert!(
2286 !tracker.tka_admits(&peer_api_only),
2287 "unsigned_peer_api_only must not exempt a peer from the tailnet-lock admission gate"
2288 );
2289
2290 // Mirror the handler's `if !tka_admits { continue }` loop: nothing reaches the peer db, so
2291 // the peer is not reachable for peerAPI either.
2292 if tracker.tka_admits(&peer_api_only) {
2293 tracker.peer_db.upsert(&peer_api_only);
2294 }
2295 assert_eq!(tracker.peer_db.peers().len(), 0);
2296 assert!(tracker.peer_db.get(&peer_api_only.node_key).is_none());
2297 }
2298
2299 #[tokio::test]
2300 async fn tka_empty_keyset_authority_admits_unsigned_peer_api_only_peer() {
2301 // The other side of `tka_active_rejects_unsigned_peer_api_only_peer`: "an authority is
2302 // present" is NOT on its own enough to drop an unsigned peer. The brick-guard fires first,
2303 // so an authority carrying no trusted keys enforces nothing and admits even the peer class
2304 // a keyed lock would reject. Pins the qualifier on the
2305 // `ts_control::Node::unsigned_peer_api_only` field docs — remove the guard and this fails.
2306 use ts_tka::{AumHash, Authority, State};
2307 let empty_auth = Authority::from_state(AumHash([0u8; 32]), State { keys: Vec::new() });
2308 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(empty_auth));
2309
2310 let mut peer_api_only = peer_node("peerapi-only", NODE_KEY_BYTES, vec![]);
2311 peer_api_only.unsigned_peer_api_only = true;
2312
2313 assert!(
2314 tracker.tka_admits(&peer_api_only),
2315 "an empty-keyset authority must not enforce, not even against an unsigned peer"
2316 );
2317
2318 tracker.apply_peer_update(
2319 &ts_control::PeerUpdate::Full(vec![peer_api_only.clone()]),
2320 local_now(),
2321 );
2322 assert!(
2323 tracker.peer_db.get(&peer_api_only.node_key).is_some(),
2324 "the peer reaches the peer db, so the gate's drop is keyset-conditional"
2325 );
2326 }
2327
2328 #[tokio::test]
2329 async fn tka_active_rejects_bad_signature() {
2330 // Authority present + a signature that fails to verify ⇒ rejected, not in peer_db.
2331 let (authority, mut sig) = authority_and_valid_sig();
2332 // Tamper the last byte (the trailing signature byte) so verification fails.
2333 let last = sig.len() - 1;
2334 sig[last] ^= 0xff;
2335
2336 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2337 let bad = peer_node("bad", NODE_KEY_BYTES, sig);
2338 assert!(!tracker.tka_admits(&bad));
2339
2340 if tracker.tka_admits(&bad) {
2341 tracker.peer_db.upsert(&bad);
2342 }
2343 assert_eq!(tracker.peer_db.peers().len(), 0);
2344 }
2345
2346 #[tokio::test]
2347 async fn tka_active_admits_authorized_peer() {
2348 // Authority present + correctly-signed node key ⇒ admitted and upserted.
2349 let (authority, sig) = authority_and_valid_sig();
2350 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2351
2352 let good = peer_node("good", NODE_KEY_BYTES, sig);
2353 assert!(tracker.tka_admits(&good));
2354
2355 if tracker.tka_admits(&good) {
2356 tracker.peer_db.upsert(&good);
2357 }
2358 assert_eq!(tracker.peer_db.peers().len(), 1);
2359 assert!(tracker.peer_db.get(&good.node_key).is_some());
2360 }
2361
2362 // ---------------------------------------------------------------------------------------------
2363 // Tests that drive REAL `PeerUpdate`s through the shared handler body
2364 // ([`PeerTracker::apply_peer_update`], the single source of truth the actor's netmap `handle`
2365 // also calls), so the two real upsert sites (`Full` and `Delta { upsert }`) are exercised via
2366 // the actual enforcement path — not by hand-mirroring `if !tka_admits { continue }`.
2367 // ---------------------------------------------------------------------------------------------
2368
2369 #[tokio::test]
2370 async fn tka_active_delta_upsert_rejects_unauthorized() {
2371 // Drive a real `Delta { upsert }` whose peer carries no signature. The Delta upsert site
2372 // must reject it under an active authority ⇒ not present in peer_db after the handler runs.
2373 let (authority, _sig) = authority_and_valid_sig();
2374 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2375
2376 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
2377 let update = ts_control::PeerUpdate::Delta {
2378 upsert: vec![unsigned.clone()],
2379 remove: Vec::new(),
2380 };
2381
2382 tracker.apply_peer_update(&update, local_now());
2383
2384 assert_eq!(tracker.peer_db.peers().len(), 0);
2385 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
2386 }
2387
2388 #[tokio::test]
2389 async fn tka_active_delta_upsert_admits_authorized() {
2390 // Drive a real `Delta { upsert }` with a correctly-signed peer ⇒ present in peer_db.
2391 let (authority, sig) = authority_and_valid_sig();
2392 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2393
2394 let good = peer_node("good", NODE_KEY_BYTES, sig);
2395 let update = ts_control::PeerUpdate::Delta {
2396 upsert: vec![good.clone()],
2397 remove: Vec::new(),
2398 };
2399
2400 tracker.apply_peer_update(&update, local_now());
2401
2402 assert_eq!(tracker.peer_db.peers().len(), 1);
2403 assert!(tracker.peer_db.get(&good.node_key).is_some());
2404 }
2405
2406 #[tokio::test]
2407 async fn tka_active_full_admits_only_authorized_in_mixed_batch() {
2408 // Drive a real `Full` carrying a MIX of authorized + unauthorized peers. Only the
2409 // correctly-signed peer survives the Full upsert site; the unsigned and bad-sig peers are
2410 // dropped fail-closed.
2411 let (authority, sig) = authority_and_valid_sig();
2412 // A bad-sig variant of the same authorized signature (tamper the trailing byte).
2413 let mut bad_sig = sig.clone();
2414 let last = bad_sig.len() - 1;
2415 bad_sig[last] ^= 0xff;
2416
2417 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2418
2419 // Only the authorized peer carries NODE_KEY_BYTES (the key the authority signed); the
2420 // rejected peers use distinct node keys so the survivor is unambiguous.
2421 let good = peer_node("good", NODE_KEY_BYTES, sig);
2422 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2423 let bad = peer_node("bad", [9u8; 32], bad_sig);
2424
2425 let update =
2426 ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]);
2427
2428 tracker.apply_peer_update(&update, local_now());
2429
2430 assert_eq!(tracker.peer_db.peers().len(), 1);
2431 assert!(tracker.peer_db.get(&good.node_key).is_some());
2432 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
2433 assert!(tracker.peer_db.get(&bad.node_key).is_none());
2434 }
2435
2436 /// End-to-end through the REAL enforcement-authority transport (the `watch` cell the control
2437 /// runner writes), not a direct field poke: writing `Some(authority)` flips enforcement on so a
2438 /// mixed batch drops the unsigned/bad peers, and a subsequent `None` (lock disabled) clears
2439 /// enforcement so a peer DROPPED while enforced is re-admitted. Exercises the exact `borrow`-based
2440 /// read path `tka_admits` uses — a broken receiver wiring would pass every for_test-field test but
2441 /// fail here.
2442 #[tokio::test]
2443 async fn tka_authority_watch_enables_then_clears_enforcement() {
2444 let (authority, sig) = authority_and_valid_sig();
2445 let mut bad_sig = sig.clone();
2446 let last = bad_sig.len() - 1;
2447 bad_sig[last] ^= 0xff;
2448
2449 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2450
2451 // 1) No authority yet ⇒ admit-all (Go b.tka == nil).
2452 let good = peer_node("good", NODE_KEY_BYTES, sig.clone());
2453 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2454 let bad = peer_node("bad", [9u8; 32], bad_sig);
2455 let batch = ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]);
2456 tracker.apply_peer_update(&batch, local_now());
2457 assert_eq!(tracker.peer_db.peers().len(), 3, "no lock ⇒ admit all");
2458
2459 // 2) Publish the verified authority over the watch cell (exactly what the control runner does
2460 // on a successful sync) ⇒ enforcement ON. A re-applied Full now drops unsigned + bad.
2461 tka_tx.send_replace(Some(Arc::new(authority)));
2462 tracker.apply_peer_update(&batch, local_now());
2463 assert_eq!(
2464 tracker.peer_db.peers().len(),
2465 1,
2466 "lock active ⇒ only the signed peer survives"
2467 );
2468 assert!(tracker.peer_db.get(&good.node_key).is_some());
2469 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
2470 assert!(tracker.peer_db.get(&bad.node_key).is_none());
2471
2472 // 3) Lock disabled (None) ⇒ enforcement cleared ⇒ a peer that was DROPPED while enforced is
2473 // re-admitted by a fresh netmap. Assert the specific previously-dropped key returns (not
2474 // merely a count), so this proves the drop→clear→re-admit transition, not "admit-all-fresh".
2475 tka_tx.send_replace(None);
2476 tracker.apply_peer_update(&batch, local_now());
2477 assert_eq!(
2478 tracker.peer_db.peers().len(),
2479 3,
2480 "lock disabled ⇒ admit all again"
2481 );
2482 assert!(
2483 tracker.peer_db.get(&unsigned.node_key).is_some(),
2484 "the peer dropped under enforcement must come back once the lock is cleared"
2485 );
2486 assert!(tracker.peer_db.get(&bad.node_key).is_some());
2487 }
2488
2489 /// The ordering gap this closes. A peer admitted BEFORE the lock synced must be re-checked the
2490 /// moment the authority is installed — not left in the db until control happens to send another
2491 /// `Full`. Go never has this problem: `SetControlClientStatus` runs `tkaSyncIfNeeded` and then
2492 /// `tkaFilterNetmapLocked(st.NetMap)` on the SAME netmap in one pass, so the netmap that
2493 /// announced the lock is itself filtered. Here the sync is a spawned task, so the netmap lands
2494 /// first and `tka_reevaluate_peer_db` is what restores Go's ordering.
2495 ///
2496 /// Note this test applies NO second netmap: the eviction must come from the authority install
2497 /// alone, which is exactly what was missing before.
2498 #[tokio::test]
2499 async fn tka_authority_install_reevaluates_already_admitted_peers() {
2500 let (authority, sig) = authority_and_valid_sig();
2501 let mut bad_sig = sig.clone();
2502 let last = bad_sig.len() - 1;
2503 bad_sig[last] ^= 0xff;
2504
2505 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2506
2507 // 1) A netmap arrives while nothing is synced ⇒ enforcement inactive ⇒ all three admitted.
2508 let good = peer_node("good", NODE_KEY_BYTES, sig);
2509 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2510 let bad = peer_node("bad", [9u8; 32], bad_sig);
2511 tracker.apply_peer_update(
2512 &ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]),
2513 local_now(),
2514 );
2515 assert_eq!(tracker.peer_db.peers().len(), 3, "no lock yet ⇒ admit all");
2516 let unsigned_id = tracker
2517 .peer_db
2518 .get(&unsigned.node_key)
2519 .expect("unsigned peer admitted while no lock is synced")
2520 .0;
2521 let bad_id = tracker
2522 .peer_db
2523 .get(&bad.node_key)
2524 .expect("bad-sig peer admitted while no lock is synced")
2525 .0;
2526
2527 // 2) The sync completes and the control runner installs the verified authority.
2528 tka_tx.send_replace(Some(Arc::new(authority)));
2529 let evicted = tracker.tka_reevaluate_peer_db();
2530
2531 assert_eq!(
2532 evicted,
2533 HashSet::from_iter([unsigned_id, bad_id]),
2534 "the unsigned and bad-signature peers are the ones reported evicted"
2535 );
2536 assert_eq!(tracker.peer_db.peers().len(), 1);
2537 assert!(
2538 tracker.peer_db.get(&good.node_key).is_some(),
2539 "the authorized peer stays admitted"
2540 );
2541 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
2542 assert!(tracker.peer_db.get(&bad.node_key).is_none());
2543
2544 // 3) Idempotent: a second pass over the now-clean db evicts nobody.
2545 assert!(tracker.tka_reevaluate_peer_db().is_empty());
2546 }
2547
2548 /// With no authority the re-evaluation evicts nobody — enforcement is inactive and every peer is
2549 /// admitted, exactly Go's `b.tka == nil` early return. Covers both "never synced" and "the lock
2550 /// was disabled after enforcing", the two ways the cell holds `None`.
2551 #[tokio::test]
2552 async fn tka_reevaluate_without_authority_evicts_nothing() {
2553 let (authority, sig) = authority_and_valid_sig();
2554 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2555
2556 let good = peer_node("good", NODE_KEY_BYTES, sig);
2557 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2558 tracker.apply_peer_update(
2559 &ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone()]),
2560 local_now(),
2561 );
2562
2563 // Never synced.
2564 assert!(tracker.tka_reevaluate_peer_db().is_empty());
2565 assert_eq!(tracker.peer_db.peers().len(), 2);
2566
2567 // Enforced, then disabled: the disable must not evict the peer the lock had authorized, and
2568 // must not start dropping the unsigned one either.
2569 tka_tx.send_replace(Some(Arc::new(authority)));
2570 assert_eq!(tracker.tka_reevaluate_peer_db().len(), 1);
2571 tka_tx.send_replace(None);
2572 assert!(tracker.tka_reevaluate_peer_db().is_empty());
2573 assert!(tracker.peer_db.get(&good.node_key).is_some());
2574 }
2575
2576 /// The re-evaluation runs the WHOLE Go `tkaFilterNetmapLocked` pass, not just the per-peer
2577 /// signature check: a peer presenting a node key that a newer rotation superseded is evicted too,
2578 /// even though its own `Direct` signature still verifies against the authority. Both peers are
2579 /// already in the db when the authority lands, so the cross-peer rotation filter has to run over
2580 /// the db contents — which is why `tka_keep_verdicts` is shared with the `Full` path rather than
2581 /// re-derived here.
2582 #[tokio::test]
2583 async fn tka_reevaluate_applies_the_cross_peer_rotation_filter() {
2584 use ed25519_dalek::SigningKey;
2585 use ts_tka::NodeKeySignature;
2586
2587 let trusted = SigningKey::from_bytes(&[42u8; 32]);
2588 let authority = Authority::from_state(
2589 AumHash([0; 32]),
2590 State {
2591 keys: vec![Key {
2592 kind: KeyKind::Ed25519,
2593 votes: 1,
2594 public: trusted.verifying_key().to_bytes().to_vec(),
2595 }],
2596 },
2597 );
2598 // `stale` holds the pivot key with a valid Direct signature; `rotated` holds a key whose
2599 // rotation chain rotated the pivot key AWAY, which obsoletes `stale`.
2600 let pivot = SigningKey::from_bytes(&[9u8; 32]);
2601 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
2602 let stale = peer_node(
2603 "stale",
2604 pivot_pub,
2605 NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize(),
2606 );
2607 let new_key = [4u8; 32];
2608 let rotated = peer_node(
2609 "rotated",
2610 new_key,
2611 NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize(),
2612 );
2613
2614 // Both admitted while nothing is synced.
2615 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2616 tracker.apply_peer_update(
2617 &ts_control::PeerUpdate::Full(vec![stale.clone(), rotated.clone()]),
2618 local_now(),
2619 );
2620 assert_eq!(tracker.peer_db.peers().len(), 2, "no lock yet ⇒ admit all");
2621
2622 tka_tx.send_replace(Some(Arc::new(authority)));
2623 let evicted = tracker.tka_reevaluate_peer_db();
2624
2625 assert_eq!(
2626 evicted.len(),
2627 1,
2628 "only the rotation-obsolete peer is evicted"
2629 );
2630 assert!(
2631 tracker.peer_db.get(&rotated.node_key).is_some(),
2632 "the freshly-rotated peer stays"
2633 );
2634 assert!(
2635 tracker.peer_db.get(&stale.node_key).is_none(),
2636 "the peer whose key a rotation superseded is evicted, though its own signature verifies"
2637 );
2638 }
2639
2640 /// A `StateUpdate` carrying nothing but a `Full` peer set — the netmap shape the live-actor test
2641 /// publishes on the bus.
2642 pub(crate) fn netmap_with_peers(peers: Vec<Node>) -> ts_control::StateUpdate {
2643 ts_control::StateUpdate {
2644 session_handle: None,
2645 seq: 0,
2646 keep_alive: false,
2647 derp: None,
2648 node: None,
2649 peer_update: Some(ts_control::PeerUpdate::Full(peers)),
2650 peer_patches: Vec::new(),
2651 user_profiles: Vec::new(),
2652 ping: None,
2653 packetfilter: None,
2654 cap_grants: None,
2655 pop_browser_url: None,
2656 dial_plan: None,
2657 dns_config: None,
2658 ssh_policy: None,
2659 tka: None,
2660 online_change: Default::default(),
2661 peer_seen_change: Default::default(),
2662 control_time: None,
2663 }
2664 }
2665
2666 /// Poll a live [`PeerTracker`] until it holds exactly `want` peers, bounded by a timeout so a
2667 /// broken wiring fails the test instead of hanging the suite.
2668 pub(crate) async fn await_peer_count(
2669 tracker: &ActorRef<PeerTracker>,
2670 want: usize,
2671 ) -> Vec<Node> {
2672 let settled = tokio::time::timeout(std::time::Duration::from_secs(10), async {
2673 loop {
2674 let peers = tracker.ask(AllPeers).await.expect("peer tracker is alive");
2675 if peers.len() == want {
2676 return peers;
2677 }
2678 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2679 }
2680 })
2681 .await;
2682 settled.unwrap_or_else(|_| panic!("peer tracker never settled at {want} peer(s)"))
2683 }
2684
2685 /// Control omits `Peers` when a node may see no peers (`omitempty`), so a netmap carrying the
2686 /// self node and no peer set is that node's complete initial state. Peer queries made before it
2687 /// (status, lookups) must be answered — empty — instead of waiting forever for a peer set.
2688 #[tokio::test]
2689 async fn self_only_netmap_answers_pending_status() {
2690 use kameo::actor::Spawn as _;
2691
2692 let env = test_env();
2693 let (_tka_tx, tka_rx) = watch::channel(None);
2694 let tracker = PeerTracker::spawn((env.clone(), tka_rx));
2695 let pending = tokio::spawn({
2696 let tracker = tracker.clone();
2697 async move { tracker.ask(GetStatus).await }
2698 });
2699 // The status query is parked until control's first netmap.
2700 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2701 assert!(!pending.is_finished());
2702
2703 let mut update = netmap_with_peers(vec![]);
2704 update.peer_update = None;
2705 update.node = Some(peer_node("self", [9u8; 32], vec![]));
2706 env.publish(Arc::new(update)).await.expect("publish netmap");
2707
2708 let status = tokio::time::timeout(std::time::Duration::from_secs(5), pending)
2709 .await
2710 .expect("status query still waiting after a self-only netmap")
2711 .unwrap()
2712 .expect("peer tracker is alive");
2713 assert!(status.is_empty());
2714 }
2715
2716 /// End-to-end through the LIVE actor, which is the only thing that proves the wiring: the peer
2717 /// tracker watches its own enforcement cell, so the control runner's `send_replace` re-filters
2718 /// the peer db with no further netmap. If the watch task were never spawned (or the message not
2719 /// handled) the unsigned peer would stay admitted forever — a hole every `for_test` unit test
2720 /// above would still pass over, because they call the re-evaluation by hand.
2721 #[tokio::test]
2722 async fn tka_authority_change_refilters_through_the_live_actor() {
2723 use kameo::actor::Spawn as _;
2724
2725 let (authority, sig) = authority_and_valid_sig();
2726 let env = test_env();
2727 let (tka_tx, tka_rx) = watch::channel(None);
2728 let tracker = PeerTracker::spawn((env.clone(), tka_rx));
2729
2730 // Await one reply first: the actor's `on_start` (which registers it on the bus) has then
2731 // completed, so the netmap published below cannot race the subscription.
2732 assert!(
2733 tracker
2734 .ask(AllPeers)
2735 .await
2736 .expect("peer tracker started")
2737 .is_empty()
2738 );
2739
2740 let good = peer_node("good", NODE_KEY_BYTES, sig);
2741 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2742 env.publish(Arc::new(netmap_with_peers(vec![
2743 good.clone(),
2744 unsigned.clone(),
2745 ])))
2746 .await
2747 .expect("publish netmap");
2748
2749 // No lock synced ⇒ both peers land.
2750 await_peer_count(&tracker, 2).await;
2751
2752 // The control runner installs the verified authority. No netmap follows.
2753 tka_tx.send_replace(Some(Arc::new(authority)));
2754
2755 let peers = await_peer_count(&tracker, 1).await;
2756 assert_eq!(
2757 peers[0].stable_id, good.stable_id,
2758 "only the authorized peer survives the authority install"
2759 );
2760 }
2761
2762 /// Degenerate input: two DISTINCT nodes sharing one `stable_id` in a single `Full`, one with a
2763 /// valid signature and one unsigned, under an active lock. Each node is judged by its OWN verdict
2764 /// (the per-node `admits` vector), so the unsigned node is never admitted on the strength of its
2765 /// signed twin. The single-verify `Full` refactor keeps this per-node semantics (a stable_id-set
2766 /// alone would have admitted whichever node was upserted last). Malformed control input; asserted
2767 /// only to lock the verdict-per-node behavior against regression.
2768 #[tokio::test]
2769 async fn tka_full_duplicate_stable_id_judges_each_node_on_its_own_signature() {
2770 let (authority, sig) = authority_and_valid_sig();
2771 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2772
2773 // Both carry stable_id "dup"; the signed one authorizes NODE_KEY_BYTES, the other is unsigned
2774 // and uses a different node key. Order them unsigned-last so a last-writer-wins stable_id set
2775 // would (wrongly) leave the unsigned node's key in the db.
2776 let signed = peer_node("dup", NODE_KEY_BYTES, sig);
2777 let unsigned = peer_node("dup", [8u8; 32], vec![]);
2778 tracker.apply_peer_update(
2779 &ts_control::PeerUpdate::Full(vec![signed.clone(), unsigned.clone()]),
2780 local_now(),
2781 );
2782
2783 // The unsigned node's own verdict failed, so its key must NOT be present, regardless of the
2784 // shared stable_id. (The signed twin retained the stable_id; the db holds the signed key.)
2785 assert!(
2786 tracker.peer_db.get(&unsigned.node_key).is_none(),
2787 "a node whose own signature fails must not be admitted via a stable_id twin"
2788 );
2789 assert!(tracker.peer_db.get(&signed.node_key).is_some());
2790 }
2791
2792 /// Full-path consistency under two KEPT nodes sharing a `stable_id`: `peer_db.upsert` is
2793 /// last-writer-wins on `stable_id`, so the db ends holding exactly one node for that id (the last
2794 /// kept), and `retain` never evicts that just-upserted id (`retained_ids` contains the shared id
2795 /// because at least one of its nodes was kept). No lock here, so both nodes are "kept". This pins
2796 /// the published-state invariant the whole-surface audit flagged: `retain` and the upsert loop
2797 /// agree on the surviving stable_id. Malformed control input; asserted for robustness.
2798 #[tokio::test]
2799 async fn tka_full_duplicate_stable_id_both_kept_is_consistent() {
2800 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2801 let first = peer_node("dup", [1u8; 32], vec![]);
2802 let last = peer_node("dup", [2u8; 32], vec![]);
2803 tracker.apply_peer_update(
2804 &ts_control::PeerUpdate::Full(vec![first.clone(), last.clone()]),
2805 local_now(),
2806 );
2807
2808 // Exactly one db entry for the shared stable_id, holding the LAST node (upsert is
2809 // last-writer-wins on stable_id); the first node's key was transparently superseded.
2810 assert_eq!(
2811 tracker.peer_db.peers().len(),
2812 1,
2813 "one entry for the shared stable_id"
2814 );
2815 assert!(
2816 tracker.peer_db.get(&last.node_key).is_some(),
2817 "the db holds the last-upserted node for the shared id"
2818 );
2819 assert!(
2820 tracker.peer_db.get(&first.node_key).is_none(),
2821 "the first node's key was superseded by the last at the shared id"
2822 );
2823 }
2824
2825 /// A peer admitted in one `Full`, then in a later `Full` presenting a key that a co-resident
2826 /// peer's rotation chain has rotated away, is EVICTED — the cross-peer rotation filter applies on
2827 /// every resync, not only at first admission. Exercises the rotation filter through two
2828 /// sequential `Full` updates with real signing.
2829 #[tokio::test]
2830 async fn tka_full_rotation_obsolete_evicts_on_resync() {
2831 use ed25519_dalek::SigningKey;
2832 use ts_tka::NodeKeySignature;
2833
2834 let trusted = SigningKey::from_bytes(&[42u8; 32]);
2835 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2836 let authority = Authority::from_state(
2837 AumHash([0; 32]),
2838 State {
2839 keys: vec![Key {
2840 kind: KeyKind::Ed25519,
2841 votes: 1,
2842 public: trusted_pub.clone(),
2843 }],
2844 },
2845 );
2846 let pivot = SigningKey::from_bytes(&[9u8; 32]);
2847 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
2848
2849 // First Full: the soon-to-be-stale peer presents the pivot key with a valid Direct sig.
2850 let stale_sig = NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize();
2851 let stale_peer = peer_node("stale", pivot_pub, stale_sig);
2852 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2853 tracker.apply_peer_update(
2854 &ts_control::PeerUpdate::Full(vec![stale_peer.clone()]),
2855 local_now(),
2856 );
2857 assert!(
2858 tracker.peer_db.get(&stale_peer.node_key).is_some(),
2859 "the stale peer is admitted while no rotation has superseded it yet"
2860 );
2861
2862 // Second Full: a freshly-rotated peer (whose chain rotated AWAY the pivot key) joins, and the
2863 // stale peer is re-included. The rotation filter now obsoletes the pivot key ⇒ stale evicted.
2864 let new_key = [4u8; 32];
2865 let new_sig = NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize();
2866 let new_peer = peer_node("rotated", new_key, new_sig);
2867 tracker.apply_peer_update(
2868 &ts_control::PeerUpdate::Full(vec![new_peer.clone(), stale_peer.clone()]),
2869 local_now(),
2870 );
2871 assert!(
2872 tracker.peer_db.get(&new_peer.node_key).is_some(),
2873 "the freshly-rotated peer is admitted"
2874 );
2875 assert!(
2876 tracker.peer_db.get(&stale_peer.node_key).is_none(),
2877 "the stale peer is EVICTED on the resync once a rotation supersedes its key"
2878 );
2879 }
2880
2881 /// The empty-trusted-key-state brick-guard: an authority with no keys must NOT drop the whole
2882 /// netmap (a `ts_tka` invariant violation / replayer edge). A verified chain always carries ≥1
2883 /// key, so this never weakens a genuine lock — it only prevents a black-hole. Uses ≥2 peers
2884 /// (one signed, one unsigned) to prove it admits **all**, not accidentally just one.
2885 #[tokio::test]
2886 async fn tka_empty_keyset_authority_admits_all() {
2887 use ts_tka::{AumHash, Authority, State};
2888 let empty_auth = Authority::from_state(AumHash([0u8; 32]), State { keys: Vec::new() });
2889 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(empty_auth));
2890 let signed = peer_node("signed", [7u8; 32], vec![0xde, 0xad]);
2891 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2892 tracker.apply_peer_update(
2893 &ts_control::PeerUpdate::Full(vec![signed.clone(), unsigned.clone()]),
2894 local_now(),
2895 );
2896 assert_eq!(
2897 tracker.peer_db.peers().len(),
2898 2,
2899 "an empty-keyset authority must admit ALL peers (brick-guard), not enforce"
2900 );
2901 }
2902
2903 /// Signature-replay / `NodeKeyMismatch`: a structurally-valid signature that authorizes
2904 /// `NODE_KEY_BYTES` must NOT admit a DIFFERENT node key carrying that same signature blob. This is
2905 /// the highest-value bypass — if the sig↔node-key binding in `verify_signature` were dropped, this
2906 /// is the only test that would catch it (the other "bad" peers only flip a byte ⇒ `BadSignature`).
2907 #[tokio::test]
2908 async fn tka_active_rejects_valid_sig_for_wrong_node_key() {
2909 let (authority, sig) = authority_and_valid_sig();
2910 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2911
2912 // The signature authorizes NODE_KEY_BYTES; attach it to an imposter with a different key.
2913 let imposter = peer_node("imposter", [0x55u8; 32], sig);
2914 assert!(
2915 !tracker.tka_admits(&imposter),
2916 "a signature bound to one node key must not authorize a different node key"
2917 );
2918 tracker.apply_peer_update(
2919 &ts_control::PeerUpdate::Full(vec![imposter.clone()]),
2920 local_now(),
2921 );
2922 assert!(tracker.peer_db.get(&imposter.node_key).is_none());
2923 }
2924
2925 /// `UntrustedKey`: a signature produced by a well-formed Ed25519 key that is NOT in the
2926 /// authority's trusted-key state must be rejected — distinct from a tampered-byte `BadSignature`.
2927 #[tokio::test]
2928 async fn tka_active_rejects_sig_from_untrusted_key() {
2929 use ed25519_dalek::{Signer, SigningKey};
2930 let (authority, _sig) = authority_and_valid_sig();
2931 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2932
2933 // Sign a valid CBOR with a DIFFERENT key (not the one the authority trusts). The key_id in
2934 // the signature names this untrusted key, so `get_key` misses ⇒ UntrustedKey.
2935 let rogue = SigningKey::from_bytes(&[99u8; 32]);
2936 let rogue_pub = rogue.verifying_key().to_bytes().to_vec();
2937 let preimage = direct_sig_cbor(&NODE_KEY_BYTES, &rogue_pub, None);
2938 let sig_hash = ts_tka::aum_hash(&preimage).0;
2939 let signature = rogue.sign(&sig_hash).to_bytes().to_vec();
2940 let rogue_cbor = direct_sig_cbor(&NODE_KEY_BYTES, &rogue_pub, Some(&signature));
2941
2942 let peer = peer_node("rogue-signed", NODE_KEY_BYTES, rogue_cbor);
2943 assert!(
2944 !tracker.tka_admits(&peer),
2945 "a signature from a key outside the trusted set must be rejected"
2946 );
2947 // Drive the real upsert path too (match the sibling replay test's depth): an untrusted-key
2948 // signature must keep the peer out of the db, not merely fail the verdict in isolation.
2949 tracker.apply_peer_update(
2950 &ts_control::PeerUpdate::Full(vec![peer.clone()]),
2951 local_now(),
2952 );
2953 assert!(tracker.peer_db.get(&peer.node_key).is_none());
2954 }
2955
2956 /// Bus-enable analogue for `Delta`: enforcement engaged via the watch cell must also gate a
2957 /// `Delta { upsert }` (not only `Full`). Closes the "authority arrived over the transport AND the
2958 /// next update is a Delta" combination.
2959 #[tokio::test]
2960 async fn tka_watch_enable_enforces_delta_upsert() {
2961 let (authority, sig) = authority_and_valid_sig();
2962 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2963 tka_tx.send_replace(Some(Arc::new(authority)));
2964
2965 let good = peer_node("good", NODE_KEY_BYTES, sig);
2966 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2967 tracker.apply_peer_update(
2968 &ts_control::PeerUpdate::Delta {
2969 remove: vec![],
2970 upsert: vec![good.clone(), unsigned.clone()],
2971 },
2972 local_now(),
2973 );
2974 assert!(tracker.peer_db.get(&good.node_key).is_some());
2975 assert!(
2976 tracker.peer_db.get(&unsigned.node_key).is_none(),
2977 "delta upsert under an active lock must drop the unsigned peer"
2978 );
2979 }
2980
2981 /// A `Delta` re-upsert of an ALREADY-ADMITTED peer whose signature is now invalid must EVICT the
2982 /// stale entry (revocation-via-delta), not leave it admitted. Go re-filters the whole netmap each
2983 /// response, so a now-unsigned peer would not survive there either.
2984 #[tokio::test]
2985 async fn tka_delta_reupsert_with_invalid_sig_evicts_existing() {
2986 let (authority, sig) = authority_and_valid_sig();
2987 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2988
2989 // Admit the signed peer.
2990 let good = peer_node("good", NODE_KEY_BYTES, sig.clone());
2991 tracker.apply_peer_update(
2992 &ts_control::PeerUpdate::Full(vec![good.clone()]),
2993 local_now(),
2994 );
2995 assert!(tracker.peer_db.get(&good.node_key).is_some());
2996
2997 // Re-upsert the SAME stable_id (now with no signature) via a delta ⇒ evicted, not retained.
2998 let revoked = peer_node("good", NODE_KEY_BYTES, vec![]);
2999 tracker.apply_peer_update(
3000 &ts_control::PeerUpdate::Delta {
3001 remove: vec![],
3002 upsert: vec![revoked],
3003 },
3004 local_now(),
3005 );
3006 assert!(
3007 tracker.peer_db.get(&good.node_key).is_none(),
3008 "a delta re-upsert that fails the lock must evict the previously-admitted peer"
3009 );
3010 }
3011
3012 #[tokio::test]
3013 async fn tka_full_resync_revocation_behavior() {
3014 // Revocation-on-resync: admit a peer, then re-include the SAME stable_id in a `Full` with a
3015 // now-invalid signature. Per the Logic review finding, the pre-fix `retain` kept the stale
3016 // (previously-admitted) entry because membership was decided purely by stable_id.
3017 //
3018 // FIXED (not merely documented): the `Full` `retain` now keys on `tka_admits`-passing
3019 // stable_ids, so a peer whose re-included signature no longer verifies under the active
3020 // authority is EVICTED. This test asserts eviction. The inactive (authority=None) path is
3021 // provably unchanged — `tka_admits` always returns `true` there, so the retained set equals
3022 // the set of re-included stable_ids exactly (see `tka_inactive_full_resync_keeps_*`).
3023 let (authority, sig) = authority_and_valid_sig();
3024 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
3025
3026 // 1) Admit the peer with a valid signature via a real `Full`.
3027 let good = peer_node("revoked", NODE_KEY_BYTES, sig.clone());
3028 tracker.apply_peer_update(
3029 &ts_control::PeerUpdate::Full(vec![good.clone()]),
3030 local_now(),
3031 );
3032 assert_eq!(tracker.peer_db.peers().len(), 1);
3033 assert!(tracker.peer_db.get(&good.node_key).is_some());
3034
3035 // 2) Re-sync the SAME stable_id, but with a now-invalid signature (tamper trailing byte).
3036 let mut bad_sig = sig;
3037 let last = bad_sig.len() - 1;
3038 bad_sig[last] ^= 0xff;
3039 let revoked = peer_node("revoked", NODE_KEY_BYTES, bad_sig);
3040 tracker.apply_peer_update(
3041 &ts_control::PeerUpdate::Full(vec![revoked.clone()]),
3042 local_now(),
3043 );
3044
3045 // Eviction: the stale entry is dropped because its re-included signature fails the gate.
3046 assert_eq!(tracker.peer_db.peers().len(), 0);
3047 assert!(tracker.peer_db.get(&revoked.node_key).is_none());
3048 }
3049
3050 #[tokio::test]
3051 async fn tka_inactive_full_resync_keeps_reincluded_peer() {
3052 // Guard the inactive (authority=None) path against the revocation fix: with no authority,
3053 // a peer re-included in a `Full` survives regardless of its signature bytes — byte-for-byte
3054 // pre-TKA behavior, proving the `Full` `retain` change does not regress the always-taken
3055 // branch this wave.
3056 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3057
3058 let peer = peer_node("p", NODE_KEY_BYTES, vec![0xde, 0xad]);
3059 tracker.apply_peer_update(
3060 &ts_control::PeerUpdate::Full(vec![peer.clone()]),
3061 local_now(),
3062 );
3063 assert_eq!(tracker.peer_db.peers().len(), 1);
3064
3065 // Re-sync the same stable_id with garbage signature bytes; inactive enforcement keeps it.
3066 let resynced = peer_node("p", NODE_KEY_BYTES, vec![0x00]);
3067 tracker.apply_peer_update(
3068 &ts_control::PeerUpdate::Full(vec![resynced.clone()]),
3069 local_now(),
3070 );
3071 assert_eq!(tracker.peer_db.peers().len(), 1);
3072 assert!(tracker.peer_db.get(&resynced.node_key).is_some());
3073 }
3074
3075 /// A `Patch` for a peer already in the netmap merges only the fields it carries — here new UDP
3076 /// endpoints and a new home DERP — leaving the rest of the node intact. This is the fix for
3077 /// dropped `peers_changed_patch`: without it the netmap keeps stale endpoints and the peer can
3078 /// never re-handshake after it moves.
3079 #[tokio::test]
3080 async fn patch_merges_endpoints_and_derp_into_existing_peer() {
3081 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3082
3083 // Seed a peer (id == 1, per `peer_node`) with no endpoints / no DERP.
3084 let peer = peer_node("mover", [1u8; 32], vec![]);
3085 tracker.apply_peer_update(
3086 &ts_control::PeerUpdate::Full(vec![peer.clone()]),
3087 local_now(),
3088 );
3089 let (_pid, before) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
3090 assert!(before.underlay_addresses.is_empty());
3091 assert!(before.derp_region.is_none());
3092
3093 // Patch in fresh reachability (the idle-peer-reconnect case).
3094 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
3095 let patch = ts_control::PeerChange {
3096 id: 1,
3097 derp_region: Some(ts_derp::RegionId(core::num::NonZeroU32::new(5).unwrap())),
3098 cap: None,
3099 cap_map: None,
3100 underlay_addresses: Some(vec![new_ep]),
3101 node_key: None,
3102 key_signature: None,
3103 disco_key: None,
3104 node_key_expiry: None,
3105 online: None,
3106 last_seen: None,
3107 };
3108 let (upserts, deletions) =
3109 tracker.apply_peer_patches(std::slice::from_ref(&patch), local_now());
3110
3111 assert_eq!(upserts.len(), 1);
3112 assert_eq!(deletions.len(), 0);
3113 // Same peer, now carrying the patched endpoint + DERP; node key untouched.
3114 assert_eq!(tracker.peer_db.peers().len(), 1);
3115 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
3116 assert_eq!(after.underlay_addresses, vec![new_ep]);
3117 assert_eq!(
3118 after.derp_region,
3119 Some(ts_derp::RegionId(core::num::NonZeroU32::new(5).unwrap()))
3120 );
3121 assert_eq!(after.node_key, peer.node_key);
3122 }
3123
3124 /// Regression for `tsr-5u0`: when a whole-node set (`Delta`/`Full`) and a patch co-occur in one
3125 /// response, the patch is applied *on top of* the node the set just upserted — mirroring the
3126 /// handler's apply-order (peer set first, then `peer_patches`). Before the fix the patch shared
3127 /// the single `peer_update` slot and the co-occurring set silently dropped it, so a peer brought
3128 /// in by the delta kept stale (empty) reachability.
3129 #[tokio::test]
3130 async fn patch_applies_on_top_of_co_occurring_delta() {
3131 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3132
3133 // The whole-node delta upserts a brand-new peer (id == 1) with no reachability.
3134 let peer = peer_node("mover", [1u8; 32], vec![]);
3135 let (set_upserts, _) = tracker.apply_peer_update(
3136 &ts_control::PeerUpdate::Delta {
3137 upsert: vec![peer.clone()],
3138 remove: vec![],
3139 },
3140 local_now(),
3141 );
3142 assert_eq!(set_upserts.len(), 1, "delta upserts the new peer");
3143
3144 // The patch from the SAME response then sets that peer's endpoints + DERP. This is exactly
3145 // the consumer order the handler runs (apply_peer_update then apply_peer_patches).
3146 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
3147 let patch = ts_control::PeerChange {
3148 id: 1,
3149 derp_region: Some(ts_derp::RegionId(core::num::NonZeroU32::new(7).unwrap())),
3150 cap: None,
3151 cap_map: None,
3152 underlay_addresses: Some(vec![new_ep]),
3153 node_key: None,
3154 key_signature: None,
3155 disco_key: None,
3156 node_key_expiry: None,
3157 online: None,
3158 last_seen: None,
3159 };
3160 let (patch_upserts, patch_deletions) =
3161 tracker.apply_peer_patches(std::slice::from_ref(&patch), local_now());
3162
3163 assert_eq!(
3164 patch_upserts.len(),
3165 1,
3166 "patch re-upserts the just-added peer"
3167 );
3168 assert_eq!(patch_deletions.len(), 0);
3169 // The peer added by the delta now carries the patched reachability — the patch was NOT lost.
3170 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
3171 assert_eq!(after.underlay_addresses, vec![new_ep]);
3172 assert_eq!(
3173 after.derp_region,
3174 Some(ts_derp::RegionId(core::num::NonZeroU32::new(7).unwrap()))
3175 );
3176 }
3177
3178 /// The node attribute by which control switches this node off the incremental netmap path
3179 /// (Go `tailcfg/nodecap`'s `DisableDeltaUpdates`).
3180 const DISABLE_DELTA_UPDATES: &str = "disable-delta-updates";
3181
3182 /// A second peer, distinct from `peer_node`'s single node in every indexed field: control node
3183 /// id, stable id, node key and tailnet addresses. Used as the peer NO patch names, so a test can
3184 /// tell "only the patched node was installed" from "the whole netmap was installed".
3185 fn other_peer_node(stable_id: &str) -> Node {
3186 let mut node = peer_node(stable_id, [2u8; 32], vec![]);
3187 node.id = 2;
3188 node.addresses = vec![
3189 "100.64.0.2/32".parse().unwrap(),
3190 "fd7a:115c:a1e0::2/128".parse().unwrap(),
3191 ];
3192 node.tailnet_address = TailnetAddress {
3193 ipv4: "100.64.0.2/32".parse().unwrap(),
3194 ipv6: "fd7a:115c:a1e0::2/128".parse().unwrap(),
3195 };
3196 node
3197 }
3198
3199 /// A self node carrying `attrs` in its capability map — the channel control uses to set node
3200 /// attributes, and the one `Node::delta_updates_disabled` reads.
3201 fn self_node_with(attrs: &[&str]) -> Node {
3202 let mut node = peer_node("self", [9u8; 32], vec![]);
3203 for attr in attrs {
3204 node.cap_map.insert((*attr).to_string(), vec![]);
3205 }
3206 node
3207 }
3208
3209 /// A reachability patch (new UDP endpoint) for the peer with control node id `id`.
3210 fn endpoint_patch(
3211 id: ts_control::NodeId,
3212 endpoint: std::net::SocketAddr,
3213 ) -> ts_control::PeerChange {
3214 ts_control::PeerChange {
3215 id,
3216 derp_region: None,
3217 cap: None,
3218 cap_map: None,
3219 underlay_addresses: Some(vec![endpoint]),
3220 node_key: None,
3221 key_signature: None,
3222 disco_key: None,
3223 node_key_expiry: None,
3224 online: None,
3225 last_seen: None,
3226 }
3227 }
3228
3229 /// The positive assertion the escape hatch is measured against: with `disable-delta-updates`
3230 /// ABSENT the patch path is exactly what it was — the patched peer is re-installed and reported,
3231 /// and a peer no patch names is left alone. This is the default and the overwhelmingly common
3232 /// case, so it is the one that must not move.
3233 #[tokio::test]
3234 async fn a_patch_without_the_attribute_installs_only_the_patched_peer() {
3235 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3236 tracker.apply_peer_update(
3237 &ts_control::PeerUpdate::Full(vec![
3238 peer_node("a", [1u8; 32], vec![]),
3239 other_peer_node("b"),
3240 ]),
3241 local_now(),
3242 );
3243 // A self node with no attributes at all: control has said nothing about delta updates.
3244 tracker.self_node = Some(self_node_with(&[]));
3245
3246 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
3247 let patch = endpoint_patch(1, new_ep);
3248 let (upserts, deletions) =
3249 tracker.apply_peer_patch_set(std::slice::from_ref(&patch), local_now());
3250
3251 assert!(deletions.is_empty());
3252 assert_eq!(
3253 upserts.len(),
3254 1,
3255 "the incremental path installs the patch's mutation, not the whole netmap"
3256 );
3257 let (patched_id, patched) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
3258 assert_eq!(patched.underlay_addresses, vec![new_ep]);
3259 assert!(upserts.contains(&patched_id));
3260 let (unpatched_id, unpatched) = tracker.peer_db.get(&(2 as ts_control::NodeId)).unwrap();
3261 assert!(
3262 unpatched.underlay_addresses.is_empty(),
3263 "a peer no patch names keeps its own reachability"
3264 );
3265 assert!(
3266 !upserts.contains(&unpatched_id),
3267 "and is not reported as installed by the delta path"
3268 );
3269 }
3270
3271 /// Control's escape hatch: with `disable-delta-updates` set, a response carrying ONLY
3272 /// `PeersChangedPatch` is still applied — as a full netmap update. The attribute declines the
3273 /// incremental arm, exactly as Go's `tryHandleIncrementally` returning `false` does; it neither
3274 /// rejects the response nor drops the patches, so asserting the patch is *ignored* here would
3275 /// pin the opposite of upstream's behaviour.
3276 #[tokio::test]
3277 async fn disable_delta_updates_applies_a_patch_only_response_as_a_full_update() {
3278 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3279 tracker.apply_peer_update(
3280 &ts_control::PeerUpdate::Full(vec![
3281 peer_node("a", [1u8; 32], vec![]),
3282 other_peer_node("b"),
3283 ]),
3284 local_now(),
3285 );
3286 tracker.self_node = Some(self_node_with(&[DISABLE_DELTA_UPDATES]));
3287
3288 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
3289 let patch = endpoint_patch(1, new_ep);
3290 let (upserts, deletions) =
3291 tracker.apply_peer_patch_set(std::slice::from_ref(&patch), local_now());
3292
3293 assert!(deletions.is_empty(), "the fall-back never drops a peer");
3294 // Applied, not dropped: the patched field is in the netmap.
3295 let (patched_id, patched) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
3296 assert_eq!(
3297 patched.underlay_addresses,
3298 vec![new_ep],
3299 "the patch is still applied under the attribute"
3300 );
3301 // And applied as a FULL update: every retained peer is re-installed and reported, not just
3302 // the one the patch named.
3303 assert_eq!(tracker.peer_db.peers().len(), 2, "no peer is evicted");
3304 let (unpatched_id, unpatched) = tracker.peer_db.get(&(2 as ts_control::NodeId)).unwrap();
3305 assert_eq!(
3306 upserts,
3307 HashSet::from_iter([patched_id, unpatched_id]),
3308 "the full arm installs the whole netmap, not the patch's mutation"
3309 );
3310 assert!(
3311 unpatched.underlay_addresses.is_empty(),
3312 "re-installing a peer no patch named does not invent state for it"
3313 );
3314 }
3315
3316 /// Under the attribute the `Peers*`-then-patch order the module documents is unchanged: the
3317 /// whole-node set is applied first (by the caller) and the patch lands on top of the node that
3318 /// set just upserted, rather than the patch being overwritten by the netmap re-install.
3319 #[tokio::test]
3320 async fn disable_delta_updates_keeps_the_peer_set_then_patch_ordering() {
3321 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3322 tracker.self_node = Some(self_node_with(&[DISABLE_DELTA_UPDATES]));
3323
3324 // The whole-node delta from this response brings in a peer with no reachability...
3325 tracker.apply_peer_update(
3326 &ts_control::PeerUpdate::Delta {
3327 upsert: vec![peer_node("mover", [1u8; 32], vec![])],
3328 remove: vec![],
3329 },
3330 local_now(),
3331 );
3332
3333 // ...and the patch from the SAME response then sets its endpoints, second.
3334 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
3335 let patch = endpoint_patch(1, new_ep);
3336 let (upserts, _deletions) =
3337 tracker.apply_peer_patch_set(std::slice::from_ref(&patch), local_now());
3338
3339 assert_eq!(upserts.len(), 1, "one peer in the netmap, so one installed");
3340 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
3341 assert_eq!(
3342 after.underlay_addresses,
3343 vec![new_ep],
3344 "the patch is applied on top of the peer set, not lost to the re-install"
3345 );
3346 }
3347
3348 /// End-to-end through the LIVE actor, which is the only thing that proves the read is wired to
3349 /// the right node and the right response: control grants `disable-delta-updates` on the self
3350 /// node of the very response that carries the patches, and the patches must still land. A
3351 /// self-node attribute read from the wrong place (or one response late) would silently leave
3352 /// this node on the incremental path control just asked it to leave.
3353 #[tokio::test]
3354 async fn disable_delta_updates_takes_effect_on_the_response_that_grants_it() {
3355 use kameo::actor::Spawn as _;
3356
3357 let env = test_env();
3358 let (_tka_tx, tka_rx) = watch::channel(None);
3359 let tracker = PeerTracker::spawn((env.clone(), tka_rx));
3360
3361 // Await one reply first so `on_start` (which subscribes the actor to the bus) has run.
3362 assert!(
3363 tracker
3364 .ask(AllPeers)
3365 .await
3366 .expect("peer tracker started")
3367 .is_empty()
3368 );
3369
3370 env.publish(Arc::new(netmap_with_peers(vec![
3371 peer_node("a", [1u8; 32], vec![]),
3372 other_peer_node("b"),
3373 ])))
3374 .await
3375 .expect("publish netmap");
3376 await_peer_count(&tracker, 2).await;
3377
3378 // One response: the self node granting the attribute, and nothing but `PeersChangedPatch`.
3379 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
3380 env.publish(Arc::new(ts_control::StateUpdate {
3381 node: Some(self_node_with(&[DISABLE_DELTA_UPDATES])),
3382 peer_update: None,
3383 peer_patches: vec![endpoint_patch(1, new_ep)],
3384 ..netmap_with_peers(Vec::new())
3385 }))
3386 .await
3387 .expect("publish the patch-only response");
3388
3389 let settled = tokio::time::timeout(std::time::Duration::from_secs(10), async {
3390 loop {
3391 let peers = tracker.ask(AllPeers).await.expect("peer tracker is alive");
3392 if peers.iter().any(|p| p.underlay_addresses == vec![new_ep]) {
3393 return peers;
3394 }
3395 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3396 }
3397 })
3398 .await;
3399 let peers = settled.expect("the patch-only response is applied under the attribute");
3400 assert_eq!(peers.len(), 2, "the fall-back to full evicts nobody");
3401 }
3402
3403 /// The node attribute by which control tells this node to stop heartbeating its peers (Go
3404 /// `tailcfg/nodecap`'s `SilentDisco`).
3405 const SILENT_DISCO: &str = "silent-disco";
3406
3407 /// A stand-in for the `Arc<PeerState>` subscribers (`DirectManager` among them) that records the
3408 /// `silent_disco` flag of every snapshot the tracker publishes, so a test can read what the
3409 /// direct manager would have been handed without standing up the socket, the dataplane and the
3410 /// DERP mesh behind it.
3411 struct SilentDiscoTap {
3412 seen: Arc<std::sync::Mutex<Vec<bool>>>,
3413 }
3414 impl kameo::Actor for SilentDiscoTap {
3415 type Args = Arc<std::sync::Mutex<Vec<bool>>>;
3416 type Error = Error;
3417 async fn on_start(seen: Self::Args, _s: ActorRef<Self>) -> Result<Self, Self::Error> {
3418 Ok(Self { seen })
3419 }
3420 }
3421 impl Message<Arc<PeerState>> for SilentDiscoTap {
3422 type Reply = ();
3423 async fn handle(&mut self, m: Arc<PeerState>, _c: &mut Context<Self, Self::Reply>) {
3424 self.seen.lock().expect("tap mutex").push(m.silent_disco);
3425 }
3426 }
3427
3428 /// End to end through the live tracker and the bus: control's `silent-disco` attribute on the
3429 /// **self** node rides the published peer snapshot, which is how it reaches
3430 /// `DirectManager` and, from there, the magicsock. Granting and withdrawing both land, and the
3431 /// attribute is read off the self node of the very response that carried it — the same timing
3432 /// `disable-delta-updates` has.
3433 #[tokio::test]
3434 async fn silent_disco_rides_the_published_peer_snapshot() {
3435 use kameo::actor::Spawn as _;
3436
3437 let env = test_env();
3438 let (_tka_tx, tka_rx) = watch::channel(None);
3439 let tracker = PeerTracker::spawn((env.clone(), tka_rx));
3440 let seen: Arc<std::sync::Mutex<Vec<bool>>> = Default::default();
3441 let tap = SilentDiscoTap::spawn(seen.clone());
3442 env.subscribe::<Arc<PeerState>>(&tap)
3443 .await
3444 .expect("subscribe the tap");
3445
3446 // Await one reply so the tracker's `on_start` (which subscribes it to the bus) has run.
3447 assert!(
3448 tracker
3449 .ask(AllPeers)
3450 .await
3451 .expect("peer tracker started")
3452 .is_empty()
3453 );
3454
3455 // A netmap whose self node carries the attribute.
3456 env.publish(Arc::new(ts_control::StateUpdate {
3457 node: Some(self_node_with(&[SILENT_DISCO])),
3458 ..netmap_with_peers(vec![peer_node("a", [1u8; 32], vec![])])
3459 }))
3460 .await
3461 .expect("publish the granting netmap");
3462 await_peer_count(&tracker, 1).await;
3463
3464 let granted = await_tap(&seen, 1).await;
3465 assert_eq!(
3466 granted.last(),
3467 Some(&true),
3468 "the attribute on the self node must reach the peer snapshot the direct manager reads"
3469 );
3470
3471 // Control withdraws it on the next netmap: the flag has to come back down, because
3472 // magicsock's setter is live and would otherwise leave the node silent forever.
3473 env.publish(Arc::new(ts_control::StateUpdate {
3474 node: Some(self_node_with(&[])),
3475 ..netmap_with_peers(vec![peer_node("a", [1u8; 32], vec![])])
3476 }))
3477 .await
3478 .expect("publish the withdrawing netmap");
3479
3480 let withdrawn = await_tap(&seen, granted.len() + 1).await;
3481 assert_eq!(
3482 withdrawn.last(),
3483 Some(&false),
3484 "withdrawing the attribute must publish the node back onto the heartbeat cadence"
3485 );
3486 }
3487
3488 /// Wait for the tap to have recorded at least `want` snapshots, returning them.
3489 async fn await_tap(seen: &Arc<std::sync::Mutex<Vec<bool>>>, want: usize) -> Vec<bool> {
3490 for _ in 0..1000 {
3491 {
3492 let got = seen.lock().expect("tap mutex");
3493 if got.len() >= want {
3494 return got.clone();
3495 }
3496 }
3497 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3498 }
3499 panic!("the tap never saw {want} published peer snapshot(s)");
3500 }
3501
3502 /// A `Patch` whose node id is not in the current netmap is ignored (the wire contract: a patch
3503 /// never creates a node). No upsert, no deletion, peer set unchanged.
3504 #[tokio::test]
3505 async fn patch_for_unknown_node_is_ignored() {
3506 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3507 let known = peer_node("known", [1u8; 32], vec![]); // id == 1
3508 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![known]), local_now());
3509
3510 let patch = ts_control::PeerChange {
3511 id: 999, // not in the netmap
3512 derp_region: None,
3513 cap: None,
3514 cap_map: None,
3515 underlay_addresses: Some(vec!["198.51.100.9:1".parse().unwrap()]),
3516 node_key: None,
3517 key_signature: None,
3518 disco_key: None,
3519 node_key_expiry: None,
3520 online: None,
3521 last_seen: None,
3522 };
3523 let (upserts, deletions) =
3524 tracker.apply_peer_patches(std::slice::from_ref(&patch), local_now());
3525
3526 assert_eq!(upserts.len(), 0);
3527 assert_eq!(deletions.len(), 0);
3528 assert_eq!(tracker.peer_db.peers().len(), 1);
3529 assert!(tracker.peer_db.get(&(999 as ts_control::NodeId)).is_none());
3530 }
3531
3532 /// An expiry-only `Patch` updates `node_key_expiry` on the matching peer (Go
3533 /// `PeerChange.KeyExpiry`), rather than being silently dropped until the next full resync.
3534 #[tokio::test]
3535 async fn patch_updates_node_key_expiry() {
3536 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3537 let peer = peer_node("expiring", [1u8; 32], vec![]); // id == 1, node_key_expiry: None
3538 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]), local_now());
3539
3540 let expiry = "2027-01-01T00:00:00Z"
3541 .parse::<chrono::DateTime<chrono::Utc>>()
3542 .unwrap();
3543 let patch = ts_control::PeerChange {
3544 id: 1,
3545 derp_region: None,
3546 cap: None,
3547 cap_map: None,
3548 underlay_addresses: None,
3549 node_key: None,
3550 key_signature: None,
3551 disco_key: None,
3552 node_key_expiry: Some(expiry),
3553 online: None,
3554 last_seen: None,
3555 };
3556 tracker.apply_peer_patches(std::slice::from_ref(&patch), local_now());
3557
3558 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
3559 assert_eq!(after.node_key_expiry, Some(expiry));
3560 }
3561
3562 /// Channel B: a `PeerChange.online` patch flips a peer's online state without a full node.
3563 #[tokio::test]
3564 async fn patch_updates_online() {
3565 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3566 let peer = peer_node("p", [1u8; 32], vec![]); // id == 1, online: None
3567 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]), local_now());
3568 assert_eq!(
3569 tracker
3570 .peer_db
3571 .get(&(1 as ts_control::NodeId))
3572 .unwrap()
3573 .1
3574 .online,
3575 None
3576 );
3577
3578 let mut patch = ts_control::PeerChange {
3579 id: 1,
3580 derp_region: None,
3581 cap: None,
3582 cap_map: None,
3583 underlay_addresses: None,
3584 node_key: None,
3585 key_signature: None,
3586 disco_key: None,
3587 node_key_expiry: None,
3588 online: Some(true),
3589 last_seen: None,
3590 };
3591 tracker.apply_peer_patches(std::slice::from_ref(&patch), local_now());
3592 assert_eq!(
3593 tracker
3594 .peer_db
3595 .get(&(1 as ts_control::NodeId))
3596 .unwrap()
3597 .1
3598 .online,
3599 Some(true),
3600 "PeerChange.online=Some(true) marks the peer online"
3601 );
3602
3603 // A subsequent patch flips it offline.
3604 patch.online = Some(false);
3605 tracker.apply_peer_patches(std::slice::from_ref(&patch), local_now());
3606 assert_eq!(
3607 tracker
3608 .peer_db
3609 .get(&(1 as ts_control::NodeId))
3610 .unwrap()
3611 .1
3612 .online,
3613 Some(false)
3614 );
3615 }
3616
3617 /// Channel C/D (Go `map.go:updatePeersStateFromResponse`): `online_change` is the sole driver of
3618 /// `online`; `peer_seen_change` is the sole driver of `last_seen` (true ⇒ now, false ⇒ cleared)
3619 /// and must NEVER touch `online`. Both apply to a peer already in the netmap and ignore unknown
3620 /// ids. This pins the fix for the prior bug where channel D wrote `online=false` (conflating
3621 /// "not seen recently" with "offline" — distinct signals in Go).
3622 #[tokio::test]
3623 async fn liveness_change_maps_apply_online() {
3624 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3625 let peer = peer_node("p", [1u8; 32], vec![]); // id == 1
3626 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]), local_now());
3627 // A fixed timestamp (chrono is built without its `clock` feature, so no `Utc::now()`).
3628 let now = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
3629
3630 // Channel C: online_change sets online=true.
3631 let mut online_change = std::collections::BTreeMap::new();
3632 online_change.insert(1 as ts_control::NodeId, true);
3633 online_change.insert(999 as ts_control::NodeId, true); // unknown id — ignored
3634 let changed = tracker.apply_liveness_changes(&online_change, &Default::default(), now);
3635 assert!(changed);
3636 assert_eq!(
3637 tracker
3638 .peer_db
3639 .get(&(1 as ts_control::NodeId))
3640 .unwrap()
3641 .1
3642 .online,
3643 Some(true)
3644 );
3645
3646 // Channel D: peer_seen_change=true sets last_seen=now and leaves online UNTOUCHED.
3647 let mut seen_true = std::collections::BTreeMap::new();
3648 seen_true.insert(1 as ts_control::NodeId, true);
3649 let changed = tracker.apply_liveness_changes(&Default::default(), &seen_true, now);
3650 assert!(changed);
3651 {
3652 let (_id, node) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
3653 assert_eq!(
3654 node.last_seen,
3655 Some(now),
3656 "peer_seen_change=true sets last_seen=now"
3657 );
3658 assert_eq!(
3659 node.online,
3660 Some(true),
3661 "channel D must NOT touch online (still true from channel C)"
3662 );
3663 }
3664
3665 // Channel D: peer_seen_change=false clears last_seen, still leaving online untouched.
3666 let mut seen_false = std::collections::BTreeMap::new();
3667 seen_false.insert(1 as ts_control::NodeId, false);
3668 let changed = tracker.apply_liveness_changes(&Default::default(), &seen_false, now);
3669 assert!(changed);
3670 {
3671 let (_id, node) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
3672 assert_eq!(
3673 node.last_seen, None,
3674 "peer_seen_change=false clears last_seen"
3675 );
3676 assert_eq!(node.online, Some(true), "channel D must NOT mark offline");
3677 }
3678 assert_eq!(
3679 tracker.peer_db.peers().len(),
3680 1,
3681 "the node is retained, not removed"
3682 );
3683
3684 // No-op when nothing matches / changes.
3685 assert!(!tracker.apply_liveness_changes(&Default::default(), &Default::default(), now));
3686 }
3687
3688 /// Security: a `Patch` that rotates the node key must re-satisfy the tailnet-lock authority,
3689 /// exactly like a `Delta` upsert. A key-rotation patch whose new signature does NOT verify
3690 /// evicts the peer (fail-closed) rather than leaving a now-unverified entry — closing what would
3691 /// otherwise be a trust-enforcement bypass via the patch path.
3692 #[tokio::test]
3693 async fn patch_key_rotation_failing_tka_evicts_peer() {
3694 let (authority, sig) = authority_and_valid_sig();
3695 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
3696
3697 // Admit a correctly-signed peer (id == 1).
3698 let good = peer_node("rotator", NODE_KEY_BYTES, sig.clone());
3699 tracker.apply_peer_update(
3700 &ts_control::PeerUpdate::Full(vec![good.clone()]),
3701 local_now(),
3702 );
3703 assert_eq!(tracker.peer_db.peers().len(), 1);
3704
3705 // Patch a new node key whose signature is garbage under the active authority.
3706 let patch = ts_control::PeerChange {
3707 id: 1,
3708 derp_region: None,
3709 cap: None,
3710 cap_map: None,
3711 underlay_addresses: None,
3712 node_key: Some([0x33u8; 32].into()),
3713 key_signature: Some(vec![0x00, 0x01, 0x02]),
3714 disco_key: None,
3715 node_key_expiry: None,
3716 online: None,
3717 last_seen: None,
3718 };
3719 let (upserts, deletions) =
3720 tracker.apply_peer_patches(std::slice::from_ref(&patch), local_now());
3721
3722 assert_eq!(upserts.len(), 0);
3723 assert_eq!(deletions.len(), 1);
3724 assert_eq!(tracker.peer_db.peers().len(), 0);
3725 }
3726
3727 /// A node's `user_id` joins against the accumulated UserProfiles table to resolve the owning
3728 /// user's profile in `WhoIs.user_profile`. With no matching profile, it is `None` (the
3729 /// pre-existing behavior); once a profile arrives, the same node resolves to it. This
3730 /// proves the accumulate-then-join path the netmap handler builds.
3731 fn profile(id: ts_control::UserId, login: &str) -> ts_control::UserProfile {
3732 ts_control::UserProfile {
3733 id,
3734 login_name: login.to_string(),
3735 display_name: None,
3736 groups: Vec::new(),
3737 }
3738 }
3739
3740 #[tokio::test]
3741 async fn whois_resolves_user_from_accumulated_profiles() {
3742 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3743
3744 // A peer owned by user id 42 at 100.64.0.1 (the peer_node fixture's address).
3745 let mut peer = peer_node("p", NODE_KEY_BYTES, Vec::new());
3746 peer.user_id = 42;
3747 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]), local_now());
3748 let addr = "100.64.0.1:0".parse().unwrap();
3749
3750 // No profile yet: the node resolves but its owner is unknown.
3751 let who = tracker.whois_opt(addr).expect("peer is known");
3752 assert_eq!(who.user_profile, None);
3753 assert_eq!(who.user(), None);
3754
3755 // Profile for a DIFFERENT user must not match.
3756 tracker
3757 .user_profiles
3758 .insert(7, profile(7, "someone-else@example.com"));
3759 assert_eq!(tracker.whois_opt(addr).unwrap().user(), None);
3760
3761 // The owning user's profile arrives (as the netmap handler would accumulate it): now the
3762 // login resolves.
3763 tracker
3764 .user_profiles
3765 .insert(42, profile(42, "alice@example.com"));
3766 assert_eq!(
3767 tracker.whois_opt(addr).unwrap().user(),
3768 Some("alice@example.com".to_string())
3769 );
3770 }
3771
3772 /// The whole carry, end to end: a real `MapResponse` body — the JSON control writes on the map
3773 /// poll — decoded by the production wire types and the production `From` impls, accumulated by
3774 /// the production profile merge, and read back out of `whois`.
3775 ///
3776 /// `Groups` is why `WhoIs` carries the profile rather than one display label: it is the only
3777 /// attribute of an owning user that a node cannot re-derive from anything else in the netmap,
3778 /// so an embedder authorising an inbound connection on group membership has no other source
3779 /// for it. Every hop here is production code; the only thing the test assembles is the
3780 /// `StateUpdate` struct itself (`ts_control`'s frame decode is not public, and its own tests
3781 /// pin the body-to-`StateUpdate` half).
3782 fn state_update_from_body(body: &str) -> ts_control::StateUpdate {
3783 let wire: ts_control_serde::MapResponse<'_> =
3784 serde_json::from_str(body).expect("a real MapResponse body decodes");
3785 let peers = wire
3786 .peers
3787 .as_ref()
3788 .expect("the fixture carries a full peer set")
3789 .iter()
3790 .map(ts_control::Node::from)
3791 .collect();
3792 ts_control::StateUpdate {
3793 user_profiles: wire
3794 .user_profiles
3795 .iter()
3796 .map(ts_control::UserProfile::from)
3797 .collect(),
3798 ..netmap_with_peers(peers)
3799 }
3800 }
3801
3802 /// The body control sends for a tailnet with one peer owned by user 42, whose profile carries
3803 /// `Groups`. `groups` is spliced in so the present and absent cases share one fixture.
3804 fn netmap_body_with_profile_groups(groups: &str) -> String {
3805 format!(
3806 r#"{{
3807 "MapSessionHandle": "sess-1",
3808 "Seq": 9,
3809 "Peers": [{{
3810 "ID": 2,
3811 "StableID": "peer-2",
3812 "Name": "peer.example.ts.net.",
3813 "Addresses": ["100.64.0.1/32", "fd7a:115c:a1e0::1/128"],
3814 "User": 42
3815 }}],
3816 "UserProfiles": [{{
3817 "ID": 42,
3818 "LoginName": "alice@example.com",
3819 "DisplayName": "Alice Smith"{groups}
3820 }}]
3821 }}"#
3822 )
3823 }
3824
3825 #[tokio::test]
3826 async fn whois_carries_user_groups_from_a_real_map_response() {
3827 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3828 let update = state_update_from_body(&netmap_body_with_profile_groups(
3829 r#", "Groups": ["engineering@example.com", "group:eng"]"#,
3830 ));
3831
3832 tracker.accumulate_user_profiles(&update.user_profiles);
3833 tracker.apply_peer_update(
3834 update.peer_update.as_ref().expect("a full peer set"),
3835 local_now(),
3836 );
3837
3838 let who = tracker
3839 .whois_opt("100.64.0.1:0".parse().unwrap())
3840 .expect("the peer owns that address");
3841
3842 let profile = who.user_profile.as_ref().expect("user 42's profile");
3843 assert_eq!(profile.id, 42);
3844 assert_eq!(profile.login_name, "alice@example.com");
3845 assert_eq!(profile.display_name.as_deref(), Some("Alice Smith"));
3846 assert_eq!(
3847 who.user_groups(),
3848 ["engineering@example.com", "group:eng"],
3849 "the groups control reported reach the embedder in the order control sent them"
3850 );
3851 // The flattened label the pre-widening `WhoIs.user` field carried is unchanged.
3852 assert_eq!(who.user(), Some("alice@example.com".to_string()));
3853 }
3854
3855 /// The absent case, which is what every control server that does not send the field looks
3856 /// like: no `Groups` key at all. That must yield a profile with an EMPTY group list — never a
3857 /// missing profile, and never a failed decode that would drop the owner identity entirely.
3858 #[tokio::test]
3859 async fn a_map_response_without_groups_yields_an_empty_group_list() {
3860 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3861 let update = state_update_from_body(&netmap_body_with_profile_groups(""));
3862
3863 tracker.accumulate_user_profiles(&update.user_profiles);
3864 tracker.apply_peer_update(
3865 update.peer_update.as_ref().expect("a full peer set"),
3866 local_now(),
3867 );
3868
3869 let who = tracker
3870 .whois_opt("100.64.0.1:0".parse().unwrap())
3871 .expect("the peer owns that address");
3872
3873 assert!(
3874 who.user_profile.is_some(),
3875 "an omitted Groups must not cost us the profile"
3876 );
3877 assert_eq!(who.user(), Some("alice@example.com".to_string()));
3878 assert!(who.user_groups().is_empty());
3879 }
3880
3881 /// Control sends profiles incrementally, so a later response restating user 42 replaces the
3882 /// held copy wholesale — including a group list that SHRANK. A membership control has revoked
3883 /// must stop being reported, or an embedder authorising on it keeps honouring it forever.
3884 #[tokio::test]
3885 async fn a_restated_profile_replaces_the_held_group_list() {
3886 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
3887 let first = state_update_from_body(&netmap_body_with_profile_groups(
3888 r#", "Groups": ["group:eng", "group:oncall"]"#,
3889 ));
3890 tracker.accumulate_user_profiles(&first.user_profiles);
3891 tracker.apply_peer_update(
3892 first.peer_update.as_ref().expect("a full peer set"),
3893 local_now(),
3894 );
3895
3896 let second = state_update_from_body(&netmap_body_with_profile_groups(
3897 r#", "Groups": ["group:eng"]"#,
3898 ));
3899 tracker.accumulate_user_profiles(&second.user_profiles);
3900
3901 let who = tracker
3902 .whois_opt("100.64.0.1:0".parse().unwrap())
3903 .expect("the peer owns that address");
3904 assert_eq!(who.user_groups(), ["group:eng"]);
3905 }
3906
3907 /// `UserProfile::best_label` prefers the login name, falling back to display name, else `None`.
3908 #[test]
3909 fn user_profile_best_label_prefers_login() {
3910 assert_eq!(
3911 profile(1, "alice@example.com").best_label(),
3912 Some("alice@example.com".to_string())
3913 );
3914 let display_only = ts_control::UserProfile {
3915 id: 2,
3916 login_name: String::new(),
3917 display_name: Some("Bob".to_string()),
3918 groups: Vec::new(),
3919 };
3920 assert_eq!(display_only.best_label(), Some("Bob".to_string()));
3921 let empty = ts_control::UserProfile {
3922 id: 3,
3923 login_name: String::new(),
3924 display_name: None,
3925 groups: Vec::new(),
3926 };
3927 assert_eq!(empty.best_label(), None);
3928 }
3929
3930 // ----- tsr-jo1: RotationTracker (Go ipnlocal.rotationTracker.obsoleteKeys) -----
3931
3932 /// A `RotationDetails` for a `Direct`-rooted chain with the given prior keys + wrapping key.
3933 fn rot_details(
3934 prev: &[&[u8]],
3935 wrapping: &[u8],
3936 kind: ts_tka::SigKind,
3937 ) -> ts_tka::RotationDetails {
3938 ts_tka::RotationDetails {
3939 prev_node_keys: prev.iter().map(|p| p.to_vec()).collect(),
3940 initial_sig_kind: kind,
3941 initial_wrapping_pubkey: wrapping.to_vec(),
3942 }
3943 }
3944
3945 /// Rule 1: every prior node key named by any rotation chain is obsolete, regardless of the
3946 /// chain's root kind (Go's ungated `obsolete.AddSlice(d.PrevNodeKeys)`).
3947 #[test]
3948 fn rotation_tracker_prev_keys_always_obsolete() {
3949 let mut t = RotationTracker::default();
3950 // A Direct-rooted chain that rotated away OLD1, and a Credential-rooted one that rotated OLD2.
3951 t.add(
3952 b"newA".to_vec(),
3953 &rot_details(&[b"OLD1"], b"wrapA", ts_tka::SigKind::Direct),
3954 );
3955 t.add(
3956 b"newB".to_vec(),
3957 &rot_details(&[b"OLD2"], b"wrapB", ts_tka::SigKind::Credential),
3958 );
3959 let obsolete = t.obsolete_keys();
3960 assert!(
3961 obsolete.contains(b"OLD1".as_slice()),
3962 "Direct chain's prior key obsolete"
3963 );
3964 assert!(
3965 obsolete.contains(b"OLD2".as_slice()),
3966 "Credential chain's prior key obsolete too (rule 1 is ungated)"
3967 );
3968 // The current keys themselves are not obsolete (only one peer per wrapping key here).
3969 assert!(!obsolete.contains(b"newA".as_slice()));
3970 assert!(!obsolete.contains(b"newB".as_slice()));
3971 }
3972
3973 /// Rule 2: among `Direct`-rooted chains sharing a wrapping key, only the longest survives; the
3974 /// shorter (older) clone's key is obsolete.
3975 #[test]
3976 fn rotation_tracker_unequal_chain_keeps_longest() {
3977 let mut t = RotationTracker::default();
3978 // Same wrapping key; "long" has 2 prior keys, "short" has 1 ⇒ "short" is the older clone.
3979 t.add(
3980 b"long".to_vec(),
3981 &rot_details(&[b"p1", b"p2"], b"wrap", ts_tka::SigKind::Direct),
3982 );
3983 t.add(
3984 b"short".to_vec(),
3985 &rot_details(&[b"q1"], b"wrap", ts_tka::SigKind::Direct),
3986 );
3987 let obsolete = t.obsolete_keys();
3988 assert!(
3989 obsolete.contains(b"short".as_slice()),
3990 "the shorter-chain clone is obsolete"
3991 );
3992 assert!(
3993 !obsolete.contains(b"long".as_slice()),
3994 "the longest-chain peer survives"
3995 );
3996 }
3997
3998 /// Rule 2 tie: two `Direct`-rooted chains sharing a wrapping key with EQUAL chain length cannot
3999 /// be disambiguated ⇒ BOTH are dropped (Go's safety branch).
4000 #[test]
4001 fn rotation_tracker_equal_chain_drops_both() {
4002 let mut t = RotationTracker::default();
4003 t.add(
4004 b"cloneA".to_vec(),
4005 &rot_details(&[b"p1"], b"wrap", ts_tka::SigKind::Direct),
4006 );
4007 t.add(
4008 b"cloneB".to_vec(),
4009 &rot_details(&[b"p2"], b"wrap", ts_tka::SigKind::Direct),
4010 );
4011 let obsolete = t.obsolete_keys();
4012 assert!(
4013 obsolete.contains(b"cloneA".as_slice()),
4014 "tied clone A dropped"
4015 );
4016 assert!(
4017 obsolete.contains(b"cloneB".as_slice()),
4018 "tied clone B dropped"
4019 );
4020 }
4021
4022 /// `Credential`-rooted chains sharing a wrapping key are EXEMPT from rule 2 (reusable-authkey
4023 /// carve-out): both are kept even with equal chain length.
4024 #[test]
4025 fn rotation_tracker_credential_root_clones_both_kept() {
4026 let mut t = RotationTracker::default();
4027 t.add(
4028 b"credA".to_vec(),
4029 &rot_details(&[b"p1"], b"wrap", ts_tka::SigKind::Credential),
4030 );
4031 t.add(
4032 b"credB".to_vec(),
4033 &rot_details(&[b"p2"], b"wrap", ts_tka::SigKind::Credential),
4034 );
4035 let obsolete = t.obsolete_keys();
4036 assert!(
4037 !obsolete.contains(b"credA".as_slice()),
4038 "credential-rooted clone A kept"
4039 );
4040 assert!(
4041 !obsolete.contains(b"credB".as_slice()),
4042 "credential-rooted clone B kept"
4043 );
4044 }
4045
4046 /// A peer that another chain already rotated away does not also act as a surviving clone: it is
4047 /// removed from its wrapping-key group before the longest-survivor pick (Go's `DeleteFunc`).
4048 #[test]
4049 fn rotation_tracker_already_obsolete_peer_not_a_survivor() {
4050 let mut t = RotationTracker::default();
4051 // "victim" is rotated away by "rotator" (different wrapping key), AND shares wrapping key
4052 // "w" with "other". Because "victim" is already obsolete, only "other" is in play for "w" and
4053 // survives (no spurious tie-drop of "other").
4054 t.add(
4055 b"rotator".to_vec(),
4056 &rot_details(&[b"victim"], b"wRot", ts_tka::SigKind::Direct),
4057 );
4058 t.add(
4059 b"victim".to_vec(),
4060 &rot_details(&[b"x"], b"w", ts_tka::SigKind::Direct),
4061 );
4062 t.add(
4063 b"other".to_vec(),
4064 &rot_details(&[b"y"], b"w", ts_tka::SigKind::Direct),
4065 );
4066 let obsolete = t.obsolete_keys();
4067 assert!(
4068 obsolete.contains(b"victim".as_slice()),
4069 "victim rotated away by rotator"
4070 );
4071 assert!(
4072 !obsolete.contains(b"other".as_slice()),
4073 "other survives — victim was removed from the group before the tie check"
4074 );
4075 }
4076
4077 /// Empty tracker (no rotation-signed peers) ⇒ no obsolete keys (the non-rotation netmap path).
4078 #[test]
4079 fn rotation_tracker_empty_is_noop() {
4080 let t = RotationTracker::default();
4081 assert!(t.obsolete_keys().is_empty());
4082 }
4083
4084 /// End-to-end through the real `Full` path: a peer presenting a freshly-rotated key (a Rotation
4085 /// chain) is admitted, while a second peer still presenting the rotated-AWAY pivot key — even with
4086 /// that key's own still-valid Direct signature — is DROPPED by the cross-peer rotation filter.
4087 /// This is the gap closed here: Go `tkaFilterNetmapLocked` drops the stale clone; we used to admit
4088 /// it. Uses real `ts_tka` signing (`sign_direct` + `sign_rotation`) so the whole
4089 /// verify → details → filter pipeline runs.
4090 ///
4091 /// Construction: the trusted key signs an inner `Direct` over the PIVOT keypair's public key; the
4092 /// pivot key then signs an outer `Rotation` authorizing `new_key`. That chain's `prev_node_keys`
4093 /// names the pivot pubkey — so a peer presenting the pivot pubkey as its node key is the
4094 /// rotated-away key the filter must drop.
4095 #[tokio::test]
4096 async fn tka_full_drops_rotated_away_key_e2e() {
4097 use ed25519_dalek::SigningKey;
4098 use ts_tka::NodeKeySignature;
4099
4100 let trusted = SigningKey::from_bytes(&[42u8; 32]);
4101 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
4102 let authority = Authority::from_state(
4103 AumHash([0; 32]),
4104 State {
4105 keys: vec![Key {
4106 kind: KeyKind::Ed25519,
4107 votes: 1,
4108 public: trusted_pub.clone(),
4109 }],
4110 },
4111 );
4112
4113 // The rotation pivot: a keypair whose public key the inner Direct authorizes and whose
4114 // private key signs the outer rotation wrap. This pivot pubkey IS the key being rotated away.
4115 let pivot = SigningKey::from_bytes(&[9u8; 32]);
4116 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
4117
4118 let new_key = [4u8; 32]; // the freshly-rotated node key
4119
4120 // Fresh peer: a Rotation chain authorizing `new_key`, inner Direct over the pivot signed by
4121 // trusted, outer wrap signed by the pivot. Its prev_node_keys names `pivot_pub`.
4122 let new_sig = NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize();
4123 let new_peer = peer_node("rotated", new_key, new_sig);
4124
4125 // Stale peer: still presents the pivot pubkey (the rotated-away key) with its own valid
4126 // Direct signature — valid in isolation, but obsoleted by the fresh peer's rotation chain.
4127 let stale_sig = NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize();
4128 let stale_peer = peer_node("stale", pivot_pub, stale_sig);
4129
4130 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
4131 tracker.apply_peer_update(
4132 &ts_control::PeerUpdate::Full(vec![new_peer.clone(), stale_peer.clone()]),
4133 local_now(),
4134 );
4135
4136 assert!(
4137 tracker.peer_db.get(&new_peer.node_key).is_some(),
4138 "the freshly-rotated peer is admitted"
4139 );
4140 assert!(
4141 tracker.peer_db.get(&stale_peer.node_key).is_none(),
4142 "the peer presenting the rotated-away key is dropped (Go tkaFilterNetmapLocked)"
4143 );
4144 }
4145}
4146
4147#[cfg(test)]
4148mod tsmp_disco_key_tests {
4149 //! Receive side of the TSMP disco-key advertisement, at the point the key is *learned*.
4150 //!
4151 //! These exercise [`PeerTracker::learn_disco_key`] — the fork's stand-in for Go
4152 //! `magicsock.Conn.HandleDiscoKeyAdvertisement` — which is the single place an advertisement
4153 //! reaches peer state. The wire decode and the "consumed, not delivered" drop are covered in
4154 //! `ts_packet::tsmp` and `ts_dataplane` respectively.
4155
4156 use ts_keys::DiscoPublicKey;
4157
4158 use super::{
4159 tka_tests::{peer_node, test_env},
4160 *,
4161 };
4162
4163 /// The key a peer advertises, and a second one for the re-advertise case.
4164 const ADVERTISED: [u8; 32] = [0xa5u8; 32];
4165 const READVERTISED: [u8; 32] = [0x5au8; 32];
4166 /// The (staler) key control has for that same peer, and the one control eventually catches up
4167 /// to.
4168 const FROM_CONTROL: [u8; 32] = [0xc0u8; 32];
4169 const CONTROL_CAUGHT_UP: [u8; 32] = [0x0cu8; 32];
4170
4171 /// The node key of the single peer these tests use.
4172 const PEER_NODE_KEY: [u8; 32] = [1u8; 32];
4173
4174 /// A tracker holding one peer with no disco key yet, plus that peer's [`PeerId`].
4175 fn tracker_with_peer() -> (PeerTracker, PeerId) {
4176 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
4177 let node = peer_node("peer", PEER_NODE_KEY, Vec::new());
4178 let id = tracker.peer_db.upsert(&node);
4179 (tracker, id)
4180 }
4181
4182 /// The peer as CONTROL describes it: the same node, carrying whatever disco key the netmap says
4183 /// it has (`None` for a peer control has no disco key for at all).
4184 fn node_from_control(disco_key: Option<[u8; 32]>) -> Node {
4185 let mut node = peer_node("peer", PEER_NODE_KEY, Vec::new());
4186 node.disco_key = disco_key.map(DiscoPublicKey::from);
4187 node
4188 }
4189
4190 /// A netmap `Full` carrying just this peer, as control currently describes it.
4191 fn control_full(disco_key: Option<[u8; 32]>) -> ts_control::PeerUpdate {
4192 ts_control::PeerUpdate::Full(vec![node_from_control(disco_key)])
4193 }
4194
4195 /// A tracker whose single peer arrived through the netmap carrying `disco_key`, exactly as the
4196 /// actor's handler applies it. Returns the peer's [`PeerId`] too.
4197 fn tracker_with_control_peer(disco_key: Option<[u8; 32]>) -> (PeerTracker, PeerId) {
4198 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
4199 let node = node_from_control(disco_key);
4200 tracker.apply_peer_update(&control_full(disco_key), local_now());
4201 let id = tracker
4202 .peer_db
4203 .has(&node.node_key)
4204 .expect("control delivered it");
4205 (tracker, id)
4206 }
4207
4208 /// The disco key the peer db currently holds for `peer` — the effective key every direct-path
4209 /// consumer resolves against.
4210 fn effective_key(tracker: &PeerTracker, peer: PeerId) -> Option<DiscoPublicKey> {
4211 tracker
4212 .peer_db
4213 .get(&peer)
4214 .expect("peer still present")
4215 .1
4216 .disco_key
4217 }
4218
4219 /// The happy path: an advertised key is applied to the peer AND lands in the disco index, which
4220 /// is what the direct-path machinery (`direct::DiscoPeerLookup`) reads. Re-advertising the same
4221 /// key is a no-op; advertising a different one replaces it, retracting the old index entry.
4222 #[tokio::test]
4223 async fn advertisement_learns_the_peers_disco_key() {
4224 let (mut tracker, peer) = tracker_with_peer();
4225 let key = DiscoPublicKey::from(ADVERTISED);
4226
4227 assert!(
4228 tracker.learn_disco_key(peer, key),
4229 "a first advertisement changes the peer db"
4230 );
4231 assert_eq!(
4232 tracker
4233 .peer_db
4234 .get(&peer)
4235 .expect("peer still present")
4236 .1
4237 .disco_key,
4238 Some(key),
4239 "the advertised disco key is learned"
4240 );
4241 assert_eq!(
4242 tracker.peer_db.has(&key),
4243 Some(peer),
4244 "and is reachable through the disco index the direct path resolves against"
4245 );
4246
4247 assert!(
4248 !tracker.learn_disco_key(peer, key),
4249 "re-advertising the same key is a no-op (Go counts it 'unchanged' and returns)"
4250 );
4251
4252 let rotated = DiscoPublicKey::from(READVERTISED);
4253 assert!(tracker.learn_disco_key(peer, rotated));
4254 assert_eq!(
4255 tracker
4256 .peer_db
4257 .get(&peer)
4258 .expect("peer still present")
4259 .1
4260 .disco_key,
4261 Some(rotated),
4262 "a later advertisement replaces the key without a netmap update"
4263 );
4264 assert_eq!(tracker.peer_db.has(&rotated), Some(peer));
4265 assert_eq!(
4266 tracker.peer_db.has(&key),
4267 None,
4268 "the superseded key no longer resolves to the peer"
4269 );
4270 }
4271
4272 /// The refusals, each of which must leave the peer db untouched: the zero key is never learned,
4273 /// and an advertisement never creates a peer.
4274 #[tokio::test]
4275 async fn refused_advertisements_change_nothing() {
4276 let (mut tracker, peer) = tracker_with_peer();
4277
4278 assert!(
4279 !tracker.learn_disco_key(peer, DiscoPublicKey::from([0u8; 32])),
4280 "the zero key is never learned"
4281 );
4282 assert_eq!(
4283 tracker
4284 .peer_db
4285 .get(&peer)
4286 .expect("peer still present")
4287 .1
4288 .disco_key,
4289 None,
4290 "a zero-key advertisement must not bind the peer to an unusable key"
4291 );
4292
4293 // An advertisement for a peer control has never told us about. Go logs "endpoint not found
4294 // for node" and returns; it must not conjure a peer into existence.
4295 let unknown = PeerId(4242);
4296 assert_eq!(tracker.peer_db.get(&unknown), None, "precondition");
4297 assert!(
4298 !tracker.learn_disco_key(unknown, DiscoPublicKey::from(ADVERTISED)),
4299 "an advertisement for an unknown peer is ignored"
4300 );
4301 assert_eq!(
4302 tracker.peer_db.peers().len(),
4303 1,
4304 "an advertisement never creates a peer — only control does"
4305 );
4306 assert_eq!(
4307 tracker.peer_db.has(&DiscoPublicKey::from(ADVERTISED)),
4308 None,
4309 "and never indexes a key against a peer that does not exist"
4310 );
4311 }
4312
4313 /// The feature's motivating case, end to end: the peer told us a key control has not caught up
4314 /// with, and then control polls again with the SAME stale key it had before. The advertisement
4315 /// must survive.
4316 ///
4317 /// Go keeps the two keys apart on the endpoint (`endpointDisco.controlKey` /
4318 /// `tsmpKey`), and `updateFromNode` only rewrites the control side when control's key actually
4319 /// changed — so a netmap restating the old key never touches the active TSMP key. With a single
4320 /// field the next map poll silently reverted the peer to control's stale key, which is precisely
4321 /// the state the advertisement exists to escape.
4322 #[tokio::test]
4323 async fn netmap_restating_controls_stale_key_keeps_the_tsmp_key() {
4324 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4325 let advertised = DiscoPublicKey::from(ADVERTISED);
4326 assert_eq!(
4327 effective_key(&tracker, peer),
4328 Some(DiscoPublicKey::from(FROM_CONTROL)),
4329 "precondition: the peer starts on the key control gave us"
4330 );
4331
4332 assert!(tracker.learn_disco_key(peer, advertised));
4333 assert_eq!(effective_key(&tracker, peer), Some(advertised));
4334
4335 // Control polls again, still behind: a `Full` resync, then a `Delta` re-upsert, both
4336 // carrying the key control already sent.
4337 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)), local_now());
4338 assert_eq!(
4339 effective_key(&tracker, peer),
4340 Some(advertised),
4341 "a Full restating control's stale key must not undo the TSMP-learned key"
4342 );
4343 tracker.apply_peer_update(
4344 &ts_control::PeerUpdate::Delta {
4345 upsert: vec![node_from_control(Some(FROM_CONTROL))],
4346 remove: vec![],
4347 },
4348 local_now(),
4349 );
4350 assert_eq!(
4351 effective_key(&tracker, peer),
4352 Some(advertised),
4353 "and neither must a Delta re-upsert of the same node"
4354 );
4355 assert_eq!(
4356 tracker.peer_db.has(&advertised),
4357 Some(peer),
4358 "the direct path still resolves the peer by the key it advertised"
4359 );
4360 assert_eq!(
4361 tracker.peer_db.has(&DiscoPublicKey::from(FROM_CONTROL)),
4362 None,
4363 "and control's superseded key does not resolve to it"
4364 );
4365
4366 // Control finally changes its mind. The new key is recorded in control's slot, but the key
4367 // the peer itself told us stays active — upstream returns to control's key only when disco
4368 // is received under it (`endpoint.checkAndUpdateDiscoKey`).
4369 tracker.apply_peer_update(&control_full(Some(CONTROL_CAUGHT_UP)), local_now());
4370 assert_eq!(
4371 effective_key(&tracker, peer),
4372 Some(advertised),
4373 "a control-side key change must not preempt an active TSMP-learned key"
4374 );
4375 assert_eq!(
4376 tracker.control_disco_key(&PEER_NODE_KEY.into()),
4377 Some(DiscoPublicKey::from(CONTROL_CAUGHT_UP)),
4378 "but control's new key IS recorded in control's slot"
4379 );
4380 }
4381
4382 /// An advertisement that merely restates the key control already gave us is still *new*
4383 /// information — it is the peer itself confirming the key — so Go records it as the TSMP key and
4384 /// makes it active. Its "unchanged" early return compares `epDisco.keyFromTSMP()`, the
4385 /// TSMP-learned key specifically, never the effective one.
4386 ///
4387 /// The observable consequence, asserted here: once the peer has confirmed the key, control
4388 /// dropping it (a netmap node with no disco key) leaves the confirmed key in place instead of
4389 /// blinding the direct path.
4390 #[tokio::test]
4391 async fn advertisement_restating_controls_key_is_recorded_as_the_tsmp_key() {
4392 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4393 let key = DiscoPublicKey::from(FROM_CONTROL);
4394
4395 assert!(
4396 tracker.learn_disco_key(peer, key),
4397 "an advertisement of the key control already sent is recorded, not dropped"
4398 );
4399 assert_eq!(
4400 tracker
4401 .endpoint_disco
4402 .get(&PEER_NODE_KEY.into())
4403 .and_then(EndpointDisco::key_from_tsmp),
4404 Some(key),
4405 "it lands in the TSMP slot (Go epDisco.tsmpKey), not only in control's"
4406 );
4407 assert!(
4408 !tracker.learn_disco_key(peer, key),
4409 "re-advertising it now IS unchanged, and is refused"
4410 );
4411
4412 // Control drops the peer's disco key. The key the peer itself confirmed stays active.
4413 tracker.apply_peer_update(&control_full(None), local_now());
4414 assert_eq!(
4415 effective_key(&tracker, peer),
4416 Some(key),
4417 "a control key going away hands the active slot to the TSMP-learned key"
4418 );
4419 assert_eq!(tracker.peer_db.has(&key), Some(peer));
4420 }
4421
4422 /// A `PeersChangedPatch` is a control write like any other: one that says nothing about the
4423 /// disco key must leave an active TSMP key alone, and one that carries a new key is control
4424 /// catching up, so it wins.
4425 ///
4426 /// The patch path is the subtle one — it starts from the db node, which carries the *effective*
4427 /// key, so without re-deriving what control last said it would hand the TSMP key back as if
4428 /// control had sent it.
4429 #[tokio::test]
4430 async fn patch_without_a_disco_key_leaves_the_tsmp_key_active() {
4431 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4432 let advertised = DiscoPublicKey::from(ADVERTISED);
4433 assert!(tracker.learn_disco_key(peer, advertised));
4434
4435 // A reachability-only patch (the idle-peer-reconnect case) for the same node.
4436 let endpoint: std::net::SocketAddr = "203.0.113.9:41641".parse().unwrap();
4437 let mut patch = ts_control::PeerChange {
4438 id: 1,
4439 derp_region: None,
4440 cap: None,
4441 cap_map: None,
4442 underlay_addresses: Some(vec![endpoint]),
4443 node_key: None,
4444 key_signature: None,
4445 disco_key: None,
4446 node_key_expiry: None,
4447 online: None,
4448 last_seen: None,
4449 };
4450 tracker.apply_peer_patches(std::slice::from_ref(&patch), local_now());
4451 assert_eq!(
4452 effective_key(&tracker, peer),
4453 Some(advertised),
4454 "a patch that never mentions the disco key must not revert it to control's"
4455 );
4456 assert_eq!(
4457 tracker
4458 .peer_db
4459 .get(&peer)
4460 .expect("peer still present")
4461 .1
4462 .underlay_addresses,
4463 vec![endpoint],
4464 "and the patch it DID carry still applied"
4465 );
4466
4467 // Now control changes the key through the patch channel. Same rule as the netmap path: the
4468 // key lands in control's slot, and the active TSMP key is left alone.
4469 patch.disco_key = Some(DiscoPublicKey::from(CONTROL_CAUGHT_UP));
4470 tracker.apply_peer_patches(std::slice::from_ref(&patch), local_now());
4471 assert_eq!(
4472 effective_key(&tracker, peer),
4473 Some(advertised),
4474 "a patch carrying a new disco key does not preempt the active TSMP-learned key either"
4475 );
4476 assert_eq!(
4477 tracker.control_disco_key(&PEER_NODE_KEY.into()),
4478 Some(DiscoPublicKey::from(CONTROL_CAUGHT_UP)),
4479 "the patched key is still recorded as what control now says"
4480 );
4481 }
4482
4483 /// The rule this whole pair of slots exists to express: once the peer has told us its key over
4484 /// TSMP, control changing its mind is *recorded* but does not take the active slot back — Go
4485 /// `endpoint.updateDiscoKey`'s `epDisco.tsmpActive = old.tsmpActive || key.IsZero()`.
4486 ///
4487 /// Control is the slower source; a key the peer sent us itself is the better evidence. Upstream
4488 /// hands the slot back only when disco is actually *received* under control's key
4489 /// (`endpoint.checkAndUpdateDiscoKey`). Here the peer re-advertising is the path back, and it is
4490 /// asserted at the end so the sticky rule cannot be read as "the TSMP key is now permanent".
4491 #[tokio::test]
4492 async fn a_control_key_change_does_not_preempt_an_active_tsmp_key() {
4493 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4494 let advertised = DiscoPublicKey::from(ADVERTISED);
4495 let caught_up = DiscoPublicKey::from(CONTROL_CAUGHT_UP);
4496 assert!(tracker.learn_disco_key(peer, advertised));
4497
4498 tracker.apply_peer_update(&control_full(Some(CONTROL_CAUGHT_UP)), local_now());
4499 assert_eq!(
4500 effective_key(&tracker, peer),
4501 Some(advertised),
4502 "the TSMP-learned key stays active across a control-side change"
4503 );
4504 assert_eq!(
4505 tracker.peer_db.has(&advertised),
4506 Some(peer),
4507 "so the direct path still resolves the peer by the key it advertised"
4508 );
4509 assert_eq!(
4510 tracker.peer_db.has(&caught_up),
4511 None,
4512 "and control's new key is not what we send to"
4513 );
4514 assert_eq!(
4515 tracker.control_disco_key(&PEER_NODE_KEY.into()),
4516 Some(caught_up),
4517 "control's new key is recorded all the same — it is not discarded, just not active"
4518 );
4519
4520 // Control changing its mind a second time, and then dropping the key entirely, changes
4521 // nothing about which key is active.
4522 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)), local_now());
4523 tracker.apply_peer_update(&control_full(None), local_now());
4524 assert_eq!(
4525 effective_key(&tracker, peer),
4526 Some(advertised),
4527 "neither a second control change nor control dropping the key moves the active slot"
4528 );
4529
4530 // The peer itself is what moves it: it advertises the key control had been trying to give
4531 // us, and that advertisement is what we act on.
4532 assert!(tracker.learn_disco_key(peer, caught_up));
4533 assert_eq!(
4534 effective_key(&tracker, peer),
4535 Some(caught_up),
4536 "a peer re-advertising moves the active key, because the peer is the evidence"
4537 );
4538 }
4539
4540 /// The sticky flag must not strand a peer that never had a TSMP key: control sending nothing
4541 /// leaves no key material at all, and the key control sends next must become the active one.
4542 ///
4543 /// This is the case Go covers by nil-ing the endpoint's `disco` pointer when both keys are
4544 /// zero; here [`PeerTracker::upsert_from_control`] drops the entry, so the "no control key means
4545 /// the TSMP slot is active" flag cannot survive to shadow a later control key with nothing.
4546 #[tokio::test]
4547 async fn a_first_control_key_is_active_even_after_control_sent_none() {
4548 let (mut tracker, peer) = tracker_with_control_peer(None);
4549 assert_eq!(effective_key(&tracker, peer), None, "precondition");
4550 assert!(
4551 tracker.endpoint_disco.is_empty(),
4552 "a peer with no key material from either source costs no entry"
4553 );
4554
4555 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)), local_now());
4556 assert_eq!(
4557 effective_key(&tracker, peer),
4558 Some(DiscoPublicKey::from(FROM_CONTROL)),
4559 "control's first key is active — there is no TSMP key for it to defer to"
4560 );
4561 assert_eq!(
4562 tracker.peer_db.has(&DiscoPublicKey::from(FROM_CONTROL)),
4563 Some(peer)
4564 );
4565 }
4566
4567 /// The other half of `tsmpActive = old.tsmpActive || key.IsZero()`, in the one state where the
4568 /// left operand is false *and* a TSMP key exists: after disco was received under control's key,
4569 /// which is upstream's only route back to control holding the active slot
4570 /// (`endpoint.checkAndUpdateDiscoKey`).
4571 ///
4572 /// Two things follow, and neither is obvious from the sticky rule alone. Control's later changes
4573 /// **do** land, because what is sticky is the flag, not the TSMP key — so this is not "the TSMP
4574 /// key wins forever", and a peer that genuinely rotated is not stranded. And control *dropping*
4575 /// its key does not leave the peer with no disco key at all: the `key.IsZero()` operand hands the
4576 /// slot to the TSMP key still sitting in the other slot, which is why Go only nils the endpoint's
4577 /// `disco` pointer when **both** keys are zero.
4578 #[tokio::test]
4579 async fn control_regains_the_slot_by_being_received_under_and_then_keeps_it() {
4580 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4581 let from_control = DiscoPublicKey::from(FROM_CONTROL);
4582 let advertised = DiscoPublicKey::from(ADVERTISED);
4583 let caught_up = DiscoPublicKey::from(CONTROL_CAUGHT_UP);
4584
4585 // Get into the state: the peer advertises, then sends disco under control's key anyway, so
4586 // control's key is active again with the TSMP key demoted but retained.
4587 assert!(tracker.learn_disco_key(peer, advertised));
4588 assert!(tracker.observe_disco_key(peer, from_control));
4589 assert_eq!(
4590 effective_key(&tracker, peer),
4591 Some(from_control),
4592 "precondition: control holds the active slot because we received under its key"
4593 );
4594 assert_eq!(
4595 ingress_match(&tracker, advertised),
4596 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
4597 "precondition: the TSMP key is demoted, not discarded"
4598 );
4599
4600 // Control changes its key. With the TSMP key demoted the sticky operand is false, so this
4601 // one does take the active slot — the flag is what is sticky, not the TSMP key.
4602 tracker.apply_peer_update(&control_full(Some(CONTROL_CAUGHT_UP)), local_now());
4603 assert_eq!(
4604 effective_key(&tracker, peer),
4605 Some(caught_up),
4606 "a demoted TSMP key does not block control's next key from becoming active"
4607 );
4608 assert_eq!(
4609 ingress_match(&tracker, advertised),
4610 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
4611 "and the TSMP key is still the peer's other known key for ingress"
4612 );
4613 assert_eq!(
4614 ingress_match(&tracker, from_control),
4615 None,
4616 "control's superseded key is not a third slot"
4617 );
4618
4619 // Control drops its key entirely. `key.IsZero()` is the operand that carries the peer here:
4620 // the retained TSMP key becomes active rather than the peer losing disco altogether.
4621 tracker.apply_peer_update(&control_full(None), local_now());
4622 assert_eq!(
4623 effective_key(&tracker, peer),
4624 Some(advertised),
4625 "control dropping its key falls back to the TSMP key, not to no key"
4626 );
4627 assert_eq!(
4628 ingress_match(&tracker, advertised),
4629 Some((peer, peer_db::DiscoKeyMatch::Active))
4630 );
4631 assert_eq!(
4632 tracker.control_disco_key(&PEER_NODE_KEY.into()),
4633 None,
4634 "control's slot is cleared, so there is no second key to accept"
4635 );
4636 assert_eq!(
4637 ingress_match(&tracker, caught_up),
4638 None,
4639 "the key control withdrew stops resolving on ingress"
4640 );
4641 }
4642
4643 /// The TSMP-learned key lives exactly as long as Go's endpoint does: it is dropped when the peer
4644 /// leaves the netmap, and it is not carried across a node-key rotation (Go builds the rotated
4645 /// peer a brand-new endpoint, with a brand-new `endpointDisco`).
4646 #[tokio::test]
4647 async fn tsmp_key_does_not_outlive_the_peer_or_its_node_key() {
4648 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4649 assert!(tracker.learn_disco_key(peer, DiscoPublicKey::from(ADVERTISED)));
4650
4651 // The peer leaves the netmap, then comes back on control's key.
4652 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![]), local_now());
4653 assert!(tracker.peer_db.peers().is_empty());
4654 assert!(
4655 tracker.endpoint_disco.is_empty(),
4656 "the departed peer's disco state goes with it"
4657 );
4658 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)), local_now());
4659 let readded = node_from_control(Some(FROM_CONTROL));
4660 let peer = tracker.peer_db.has(&readded.node_key).expect("re-added");
4661 assert_eq!(
4662 effective_key(&tracker, peer),
4663 Some(DiscoPublicKey::from(FROM_CONTROL)),
4664 "a peer that left and rejoined starts from control's key again"
4665 );
4666
4667 // Learn a key again, then rotate the node key underneath it.
4668 assert!(tracker.learn_disco_key(peer, DiscoPublicKey::from(READVERTISED)));
4669 let mut rotated = node_from_control(Some(FROM_CONTROL));
4670 rotated.node_key = [2u8; 32].into();
4671 tracker.apply_peer_update(
4672 &ts_control::PeerUpdate::Full(vec![rotated.clone()]),
4673 local_now(),
4674 );
4675 let peer = tracker
4676 .peer_db
4677 .has(&rotated.node_key)
4678 .expect("rotated peer");
4679 assert_eq!(
4680 effective_key(&tracker, peer),
4681 Some(DiscoPublicKey::from(FROM_CONTROL)),
4682 "a key learned under the old node key is not carried onto the new one"
4683 );
4684 assert_eq!(
4685 tracker.endpoint_disco.len(),
4686 1,
4687 "and the old node key's state is pruned"
4688 );
4689 }
4690
4691 /// How the peer db resolves `key` for an inbound disco frame: the peer it belongs to and which
4692 /// of that peer's two slots it matched.
4693 fn ingress_match(
4694 tracker: &PeerTracker,
4695 key: DiscoPublicKey,
4696 ) -> Option<(PeerId, peer_db::DiscoKeyMatch)> {
4697 tracker
4698 .peer_db
4699 .peer_by_known_disco_key(&key)
4700 .map(|(id, _node, matched)| (id, matched))
4701 }
4702
4703 /// The bead's case, end to end: the peer advertised K2 over TSMP so we send to K2, but it is
4704 /// still sending disco under the K1 control gave us. That frame must resolve to the peer, and
4705 /// receiving under K1 must make K1 the key we send to — because it is demonstrably what the
4706 /// peer uses.
4707 ///
4708 /// Go: every inbound disco comparison goes through `endpoint.checkAndUpdateDiscoKey`, which
4709 /// accepts either slot and compare-and-swaps `tsmpActive` when the key seen is the inactive one.
4710 #[tokio::test]
4711 async fn disco_under_the_inactive_key_is_accepted_and_makes_that_key_active() {
4712 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4713 let from_control = DiscoPublicKey::from(FROM_CONTROL);
4714 let advertised = DiscoPublicKey::from(ADVERTISED);
4715
4716 assert!(tracker.learn_disco_key(peer, advertised));
4717 assert_eq!(
4718 effective_key(&tracker, peer),
4719 Some(advertised),
4720 "precondition: we are sending to the TSMP-learned key"
4721 );
4722 assert_eq!(
4723 ingress_match(&tracker, from_control),
4724 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
4725 "control's key is still the peer's other known key, and still resolves on ingress"
4726 );
4727
4728 // Disco arrives under control's key: accepted, and it becomes the active one.
4729 assert!(
4730 tracker.observe_disco_key(peer, from_control),
4731 "receiving under the inactive key switches the active key"
4732 );
4733 assert_eq!(
4734 effective_key(&tracker, peer),
4735 Some(from_control),
4736 "we now send to the key the peer is demonstrably using"
4737 );
4738 assert_eq!(
4739 tracker.peer_db.has(&from_control),
4740 Some(peer),
4741 "and it is the key the send-side disco index carries"
4742 );
4743 assert_eq!(
4744 ingress_match(&tracker, from_control),
4745 Some((peer, peer_db::DiscoKeyMatch::Active))
4746 );
4747 assert_eq!(
4748 ingress_match(&tracker, advertised),
4749 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
4750 "the TSMP key is retained in the other slot, so ingress under it still resolves"
4751 );
4752
4753 assert!(
4754 !tracker.observe_disco_key(peer, from_control),
4755 "a second frame under the now-active key changes nothing (and forces no republish)"
4756 );
4757
4758 // And it switches back: the peer resumes sending under the key it advertised.
4759 assert!(tracker.observe_disco_key(peer, advertised));
4760 assert_eq!(effective_key(&tracker, peer), Some(advertised));
4761 assert_eq!(
4762 ingress_match(&tracker, from_control),
4763 Some((peer, peer_db::DiscoKeyMatch::Inactive))
4764 );
4765 }
4766
4767 /// The refusal that is the whole security value of the check: a key belonging to NEITHER slot
4768 /// is rejected, leaving the peer on the key it was on. Plus the two other refusals Go has —
4769 /// an unknown peer, and a peer with no disco key material at all (`epDisco == nil`).
4770 #[tokio::test]
4771 async fn disco_under_a_key_in_neither_slot_is_refused() {
4772 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4773 let from_control = DiscoPublicKey::from(FROM_CONTROL);
4774 let advertised = DiscoPublicKey::from(ADVERTISED);
4775 let third = DiscoPublicKey::from(READVERTISED);
4776
4777 assert!(tracker.learn_disco_key(peer, advertised));
4778
4779 assert!(
4780 !tracker.observe_disco_key(peer, third),
4781 "a third key is refused: a peer must not move itself onto a key nobody told us about"
4782 );
4783 assert_eq!(
4784 effective_key(&tracker, peer),
4785 Some(advertised),
4786 "and the peer stays on the key it was on"
4787 );
4788 assert_eq!(
4789 ingress_match(&tracker, third),
4790 None,
4791 "the refused key never becomes resolvable"
4792 );
4793 assert_eq!(
4794 ingress_match(&tracker, from_control),
4795 Some((peer, peer_db::DiscoKeyMatch::Inactive)),
4796 "the two real slots are untouched"
4797 );
4798
4799 // An unknown peer: like a TSMP advertisement, this never creates one.
4800 assert!(!tracker.observe_disco_key(PeerId(4242), from_control));
4801 assert_eq!(tracker.peer_db.peers().len(), 1);
4802
4803 // A peer with no disco key from either source — Go returns false on `epDisco == nil`.
4804 let (mut bare, bare_peer) = tracker_with_control_peer(None);
4805 assert_eq!(effective_key(&bare, bare_peer), None, "precondition");
4806 assert!(
4807 !bare.observe_disco_key(bare_peer, from_control),
4808 "a peer with no known disco key has no slot for this key to match"
4809 );
4810 assert_eq!(effective_key(&bare, bare_peer), None);
4811 }
4812
4813 /// A peer that has only ever had one key registers no inactive key at all, so the second index
4814 /// stays empty and an inbound frame under any other key is refused.
4815 #[tokio::test]
4816 async fn a_single_key_peer_has_no_second_slot() {
4817 let (tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4818 let from_control = DiscoPublicKey::from(FROM_CONTROL);
4819
4820 assert_eq!(
4821 ingress_match(&tracker, from_control),
4822 Some((peer, peer_db::DiscoKeyMatch::Active))
4823 );
4824 assert_eq!(
4825 ingress_match(&tracker, DiscoPublicKey::from(ADVERTISED)),
4826 None,
4827 "no second key was ever learned, so nothing else resolves to this peer"
4828 );
4829 }
4830
4831 /// An advertisement that merely restates control's key must not leave the peer with the same
4832 /// key in both slots pretending to be two — `inactive_key` reports `None` when the inactive
4833 /// slot holds the active key, so ingress sees exactly one key.
4834 #[tokio::test]
4835 async fn the_same_key_in_both_slots_is_one_key() {
4836 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4837 let from_control = DiscoPublicKey::from(FROM_CONTROL);
4838
4839 assert!(tracker.learn_disco_key(peer, from_control));
4840 assert_eq!(
4841 tracker
4842 .endpoint_disco
4843 .get(&PEER_NODE_KEY.into())
4844 .and_then(EndpointDisco::inactive_key),
4845 None,
4846 "both slots hold the same key, so there is no second key"
4847 );
4848 assert_eq!(
4849 ingress_match(&tracker, from_control),
4850 Some((peer, peer_db::DiscoKeyMatch::Active))
4851 );
4852 assert!(
4853 !tracker.observe_disco_key(peer, from_control),
4854 "and receiving under it is a no-op, not a switch"
4855 );
4856 }
4857
4858 /// A peer that leaves the netmap takes BOTH its keys with it: the inactive-key index must not
4859 /// keep attributing frames to a peer that is gone.
4860 #[tokio::test]
4861 async fn a_departed_peer_stops_resolving_under_either_key() {
4862 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
4863 let advertised = DiscoPublicKey::from(ADVERTISED);
4864 assert!(tracker.learn_disco_key(peer, advertised));
4865 assert!(ingress_match(&tracker, DiscoPublicKey::from(FROM_CONTROL)).is_some());
4866
4867 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![]), local_now());
4868 assert_eq!(ingress_match(&tracker, advertised), None);
4869 assert_eq!(
4870 ingress_match(&tracker, DiscoPublicKey::from(FROM_CONTROL)),
4871 None,
4872 "the inactive key is retracted with the peer, not left dangling"
4873 );
4874 }
4875}
4876
4877#[cfg(test)]
4878mod index_eviction_tests {
4879 //! A departing peer must not evict the index rows of the peer that took its place.
4880 //!
4881 //! Control reassigns a churning (typically ephemeral) peer's tailnet IP and MagicDNS name to a
4882 //! newer node, and the newer node's upsert can reach us before the old node's removal — either
4883 //! in an earlier `MapResponse`, or reordered inside one batch. Here it is not even a race:
4884 //! [`PeerTracker::apply_peer_update`] applies a delta's upserts first and its removals second,
4885 //! so the intra-batch ordering is the one this tree ALWAYS uses. These drive real
4886 //! [`ts_control::PeerUpdate`]s through that function and assert the lookups an embedder
4887 //! actually depends on — `peer_by_tailnet_ip` (whois, peerAPI source checks) and
4888 //! `peer_by_name` — still answer with the live peer.
4889
4890 use super::{
4891 tka_tests::{peer_node, test_env},
4892 *,
4893 };
4894
4895 /// A peer holding a specific control node id, tailnet address pair and hostname.
4896 fn peer_at(stable_id: &str, control_id: i64, key: u8, host: u8) -> Node {
4897 let mut node = peer_node(stable_id, [key; 32], Vec::new());
4898 node.id = control_id;
4899 node.hostname = stable_id.to_string();
4900 node.tailnet = Some("ts.net".to_string());
4901
4902 let ipv4: ipnet::Ipv4Net = format!("100.64.0.{host}/32").parse().unwrap();
4903 let ipv6: ipnet::Ipv6Net = format!("fd7a:115c:a1e0::{host}/128").parse().unwrap();
4904 node.addresses = vec![ipv4.into(), ipv6.into()];
4905 node.tailnet_address = ts_control::TailnetAddress { ipv4, ipv6 };
4906 node.disco_key = Some([key; 32].into());
4907 node
4908 }
4909
4910 fn tailnet_ipv4(host: u8) -> IpAddr {
4911 format!("100.64.0.{host}").parse().unwrap()
4912 }
4913
4914 /// The successor's upsert and the departing peer's removal in ONE delta — the ordering
4915 /// `apply_peer_update` always applies (upserts, then removals).
4916 #[tokio::test]
4917 async fn a_delta_that_replaces_a_peer_keeps_the_successor_addressable() {
4918 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
4919 let now = local_now();
4920
4921 let departing = peer_at("departing", 1, 1, 9);
4922 tracker.apply_peer_update(
4923 &ts_control::PeerUpdate::Delta {
4924 upsert: vec![departing.clone()],
4925 remove: Vec::new(),
4926 },
4927 now,
4928 );
4929 assert_eq!(
4930 tracker
4931 .peer_by_tailnet_ip_opt(tailnet_ipv4(9))
4932 .map(|n| &n.stable_id),
4933 Some(&departing.stable_id)
4934 );
4935
4936 // Control hands the address and the MagicDNS name to a new node and retires the old one in
4937 // the same batch.
4938 let mut successor = peer_at("successor", 2, 2, 9);
4939 successor.hostname = departing.hostname.clone();
4940 successor.disco_key = departing.disco_key;
4941
4942 tracker.apply_peer_update(
4943 &ts_control::PeerUpdate::Delta {
4944 upsert: vec![successor.clone()],
4945 remove: vec![departing.id],
4946 },
4947 now,
4948 );
4949
4950 assert_eq!(tracker.peer_db.peers().len(), 1, "the old peer is gone");
4951 assert_eq!(
4952 tracker
4953 .peer_by_tailnet_ip_opt(tailnet_ipv4(9))
4954 .map(|n| &n.stable_id),
4955 Some(&successor.stable_id),
4956 "whois and every peerAPI source check resolve through this index"
4957 );
4958 assert_eq!(
4959 tracker
4960 .peer_by_name_opt("departing.ts.net")
4961 .map(|n| &n.stable_id),
4962 Some(&successor.stable_id),
4963 "the MagicDNS name follows the address to its new owner"
4964 );
4965 assert_eq!(
4966 tracker
4967 .peer_db
4968 .get(&successor.disco_key.unwrap())
4969 .map(|(_id, n)| &n.stable_id),
4970 Some(&successor.stable_id),
4971 "and so does the disco key, which is the successor's direct path"
4972 );
4973 }
4974
4975 /// The same replacement split across TWO deltas: the successor arrives in one `MapResponse`,
4976 /// the departing peer's removal trails in a later one.
4977 #[tokio::test]
4978 async fn a_removal_trailing_a_later_upsert_keeps_the_successor_addressable() {
4979 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
4980 let now = local_now();
4981
4982 let departing = peer_at("departing", 1, 1, 9);
4983 let successor = peer_at("successor", 2, 2, 9);
4984
4985 tracker.apply_peer_update(
4986 &ts_control::PeerUpdate::Delta {
4987 upsert: vec![departing.clone()],
4988 remove: Vec::new(),
4989 },
4990 now,
4991 );
4992 tracker.apply_peer_update(
4993 &ts_control::PeerUpdate::Delta {
4994 upsert: vec![successor.clone()],
4995 remove: Vec::new(),
4996 },
4997 now,
4998 );
4999 tracker.apply_peer_update(
5000 &ts_control::PeerUpdate::Delta {
5001 upsert: Vec::new(),
5002 remove: vec![departing.id],
5003 },
5004 now,
5005 );
5006
5007 assert_eq!(tracker.peer_db.peers().len(), 1);
5008 assert_eq!(
5009 tracker
5010 .peer_by_tailnet_ip_opt(tailnet_ipv4(9))
5011 .map(|n| &n.stable_id),
5012 Some(&successor.stable_id),
5013 "a removal that arrives late must not evict the address's live owner"
5014 );
5015 assert!(
5016 tracker
5017 .whois_opt("100.64.0.9:80".parse().unwrap())
5018 .is_some(),
5019 "so whois still identifies the peer that is present and handshaking"
5020 );
5021 }
5022
5023 /// The positive case, through the same path: a peer removed while it still owns its rows is
5024 /// really gone from every index, so the guard cannot pass by never evicting anything.
5025 #[tokio::test]
5026 async fn a_removal_with_no_successor_clears_the_indexes() {
5027 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
5028 let now = local_now();
5029
5030 let peer = peer_at("solo", 1, 1, 9);
5031 tracker.apply_peer_update(
5032 &ts_control::PeerUpdate::Delta {
5033 upsert: vec![peer.clone()],
5034 remove: Vec::new(),
5035 },
5036 now,
5037 );
5038 tracker.apply_peer_update(
5039 &ts_control::PeerUpdate::Delta {
5040 upsert: Vec::new(),
5041 remove: vec![peer.id],
5042 },
5043 now,
5044 );
5045
5046 assert!(tracker.peer_db.peers().is_empty());
5047 assert!(tracker.peer_by_tailnet_ip_opt(tailnet_ipv4(9)).is_none());
5048 assert!(tracker.peer_by_name_opt("solo.ts.net").is_none());
5049 assert!(tracker.peer_db.get(&peer.node_key).is_none());
5050 assert!(tracker.peer_db.get(&peer.stable_id).is_none());
5051 assert!(
5052 tracker
5053 .whois_opt("100.64.0.9:80".parse().unwrap())
5054 .is_none(),
5055 "a departed peer must not stay attributable by its old address"
5056 );
5057 }
5058}
5059
5060#[cfg(test)]
5061mod expiry_tests {
5062 //! Node-key expiry enforcement at the peer tracker — the port of Go's `expiryManager`
5063 //! (`ipn/ipnlocal/expiry.go`) wired into this fork's netmap.
5064 //!
5065 //! [`ts_control::ExpiryManager`] carries its own unit tests for the decision itself. These
5066 //! cover the wiring: that the pass runs at the install site, that the state it leaves is
5067 //! observable through [`StatusNode`], and that the timer catches a peer that expires with **no
5068 //! netmap in between** — the case the timer exists for.
5069
5070 use chrono::TimeDelta;
5071 use kameo::actor::Spawn as _;
5072
5073 use super::{
5074 tka_tests::{await_peer_count, netmap_with_peers, peer_node, test_env},
5075 *,
5076 };
5077
5078 /// A peer with a chosen key expiry, endpoints and a DERP home — the three things the expiry
5079 /// pass strips.
5080 fn expiring_peer(
5081 stable_id: &str,
5082 key: u8,
5083 expiry: Option<chrono::DateTime<chrono::Utc>>,
5084 ) -> Node {
5085 let mut node = peer_node(stable_id, [key; 32], Vec::new());
5086 node.node_key_expiry = expiry;
5087 node.underlay_addresses = vec!["192.0.2.9:41641".parse().unwrap()];
5088 node.derp_region = Some(ts_derp::RegionId(core::num::NonZeroU32::new(3).unwrap()));
5089 node.peerapi_port = Some(8080);
5090 node
5091 }
5092
5093 /// The fail-closed direction here is to **flag, not drop**: Go deliberately keeps an expired
5094 /// peer in the netmap so callers can give a clear error, and removing it would lose that. The
5095 /// peer stays addressable by stable id while losing everything that could carry traffic.
5096 #[tokio::test]
5097 async fn an_expired_peer_is_flagged_and_kept_not_dropped() {
5098 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
5099 let now = local_now();
5100 let peer = expiring_peer("eXpIrEd", 7, Some(now - TimeDelta::hours(1)));
5101 let pristine_key = peer.node_key;
5102
5103 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]), now);
5104
5105 let (_id, stored) = tracker
5106 .peer_db
5107 .get(&peer.stable_id)
5108 .expect("the expired peer is KEPT in the netmap, not dropped");
5109 assert!(stored.expired);
5110 assert!(
5111 stored.underlay_addresses.is_empty(),
5112 "endpoints are cleared"
5113 );
5114 assert_eq!(stored.derp_region, None, "the DERP home is cleared");
5115 assert_eq!(
5116 stored.node_key,
5117 ts_keys::node_public_with_bad_old_prefix(pristine_key),
5118 "the node key is broken, so nothing can handshake with the peer"
5119 );
5120 assert_eq!(
5121 stored.peerapi_addr(),
5122 None,
5123 "a peerAPI dial to the expired peer is refused"
5124 );
5125
5126 let status = tracker.status_peers();
5127 assert_eq!(status.len(), 1);
5128 assert!(status[0].expired, "the state is observable to a watcher");
5129 }
5130
5131 /// The negative case: a tagged node carries no key expiry at all (Go's zero `KeyExpiry`) and is
5132 /// never flagged, however far the clock is pushed.
5133 #[tokio::test]
5134 async fn a_peer_with_no_expiry_is_never_flagged() {
5135 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
5136 let now = local_now();
5137 let tagged = expiring_peer("tAgGeD", 8, None);
5138
5139 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![tagged.clone()]), now);
5140 assert!(
5141 tracker
5142 .reevaluate_expiry(now + TimeDelta::days(3650))
5143 .is_empty(),
5144 "a node with no expiry never expires, ten years on"
5145 );
5146
5147 let (_id, stored) = tracker
5148 .peer_db
5149 .get(&tagged.stable_id)
5150 .expect("still a peer");
5151 assert!(!stored.expired);
5152 assert_eq!(stored.node_key, tagged.node_key, "its key is left alone");
5153 assert_eq!(stored.underlay_addresses, tagged.underlay_addresses);
5154 assert_eq!(stored.derp_region, tagged.derp_region);
5155 }
5156
5157 /// The case the timer exists for: a peer that is perfectly live when it is installed, and whose
5158 /// key expiry then passes with **no netmap in between**. `reevaluate_expiry` is what the fired
5159 /// timer runs.
5160 #[tokio::test]
5161 async fn a_peer_that_expires_between_netmaps_is_flagged_by_the_timer_pass() {
5162 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
5163 let now = local_now();
5164 let peer = expiring_peer("lIvE", 9, Some(now + TimeDelta::hours(1)));
5165
5166 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]), now);
5167 let (_id, stored) = tracker.peer_db.get(&peer.stable_id).expect("installed");
5168 assert!(!stored.expired, "not expired when control handed it to us");
5169
5170 // No netmap arrives. The clock crosses the peer's expiry.
5171 let upserts = tracker.reevaluate_expiry(now + TimeDelta::hours(2));
5172
5173 assert_eq!(upserts.len(), 1, "the peer is re-installed, flagged");
5174 let (_id, stored) = tracker
5175 .peer_db
5176 .get(&peer.stable_id)
5177 .expect("still kept, just flagged");
5178 assert!(stored.expired);
5179 assert!(stored.underlay_addresses.is_empty());
5180 assert_eq!(stored.derp_region, None);
5181 }
5182
5183 /// An already-expired peer must be skipped rather than re-flagged, or the log and the
5184 /// invalidation it triggers repeat on every pass — and the broken key would be re-broken.
5185 #[tokio::test]
5186 async fn an_already_flagged_peer_is_not_reflagged() {
5187 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
5188 let now = local_now();
5189 let peer = expiring_peer("lIvE", 9, Some(now + TimeDelta::hours(1)));
5190 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]), now);
5191
5192 let later = now + TimeDelta::hours(2);
5193 assert_eq!(tracker.reevaluate_expiry(later).len(), 1);
5194 let after_first = tracker
5195 .peer_db
5196 .get(&peer.stable_id)
5197 .expect("kept")
5198 .1
5199 .clone();
5200
5201 assert!(
5202 tracker.reevaluate_expiry(later).is_empty(),
5203 "the second pass reports no transition, so nothing is re-published"
5204 );
5205 assert_eq!(
5206 tracker.peer_db.get(&peer.stable_id).expect("kept").1,
5207 &after_first,
5208 "and nothing is mutated a second time"
5209 );
5210 }
5211
5212 /// End to end through the LIVE actor, which is the only thing that proves the wiring: a peer
5213 /// that is live when the netmap installs it, and whose key then expires while the map poll sits
5214 /// idle, is flagged by the timer alone. If the timer were never armed — or its firing never
5215 /// re-ran the pass — the peer would keep its endpoints, its DERP home and a usable node key
5216 /// until control happened to send another netmap.
5217 #[tokio::test]
5218 async fn a_key_expiring_with_no_netmap_in_between_is_caught_by_the_live_timer() {
5219 let env = test_env();
5220 let (_tka_tx, tka_rx) = watch::channel(None);
5221 let live = PeerTracker::spawn((env.clone(), tka_rx));
5222 assert!(live.ask(AllPeers).await.expect("started").is_empty());
5223
5224 // Expires shortly, but strictly in the future: the install-time pass must NOT flag it. The
5225 // window has to outlast actor start + publish + one ask on a loaded box, hence seconds
5226 // rather than milliseconds; the test does not wait it out, it only waits for the wall clock
5227 // to cross it (below).
5228 let expiry = local_now() + TimeDelta::seconds(2);
5229 let peer = expiring_peer("sHoRtLiVeD", 4, Some(expiry));
5230 env.publish(Arc::new(netmap_with_peers(vec![peer.clone()])))
5231 .await
5232 .expect("publish netmap");
5233
5234 let installed = await_peer_count(&live, 1).await;
5235 assert!(
5236 local_now() < expiry,
5237 "the install has to finish inside the window, or this test is not testing the timer"
5238 );
5239 assert!(
5240 !installed[0].expired,
5241 "still live when control handed it to us"
5242 );
5243
5244 // Let the key really expire on the wall clock the pass reads...
5245 while local_now() <= expiry {
5246 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
5247 }
5248 // ...then jump the runtime's timer wheel past the armed delay (the peer's expiry plus Go's
5249 // slack) so the timer fires now instead of ten seconds from now. No netmap in between.
5250 tokio::time::pause();
5251 tokio::time::advance(std::time::Duration::from_secs(
5252 ts_control::EXPIRY_TIMER_SLACK_SECS as u64 + 5,
5253 ))
5254 .await;
5255 tokio::time::resume();
5256
5257 let flagged = tokio::time::timeout(std::time::Duration::from_secs(10), async {
5258 loop {
5259 let peers = live.ask(AllPeers).await.expect("peer tracker is alive");
5260 if peers.first().is_some_and(|p| p.expired) {
5261 return peers;
5262 }
5263 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
5264 }
5265 })
5266 .await
5267 .expect("the expiry timer flagged the peer with no netmap in between");
5268
5269 assert_eq!(flagged.len(), 1, "flagged, not dropped");
5270 assert!(flagged[0].underlay_addresses.is_empty());
5271 assert_eq!(flagged[0].derp_region, None);
5272 assert_eq!(
5273 flagged[0].node_key,
5274 ts_keys::node_public_with_bad_old_prefix(peer.node_key)
5275 );
5276 }
5277
5278 /// The recovery path, through the channel that actually carries it: control extends an expired
5279 /// peer's key with a `PeerChange` that restates only `KeyExpiry`. The peer has to come back
5280 /// with its direct candidates and its home DERP, not merely with a usable node key — flagging
5281 /// cleared all three, the patch restates none of them, and a peer that is un-expired but has
5282 /// neither an endpoint nor a DERP home is unroutable until the next full netmap.
5283 #[tokio::test]
5284 async fn an_expiry_only_patch_restores_the_peers_routes() {
5285 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
5286 let now = local_now();
5287 let peer = expiring_peer("eXtEnDeD", 6, Some(now - TimeDelta::hours(1)));
5288
5289 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]), now);
5290 assert!(
5291 tracker
5292 .peer_db
5293 .get(&peer.stable_id)
5294 .expect("kept")
5295 .1
5296 .expired,
5297 "flagged on the way in"
5298 );
5299
5300 // Exactly the shape of a `PeerChange` that only extends the key's life.
5301 let patch = ts_control::PeerChange {
5302 id: peer.id,
5303 derp_region: None,
5304 cap: None,
5305 cap_map: None,
5306 underlay_addresses: None,
5307 node_key: None,
5308 key_signature: None,
5309 disco_key: None,
5310 node_key_expiry: Some(now + TimeDelta::days(30)),
5311 online: None,
5312 last_seen: None,
5313 };
5314 tracker.apply_peer_patches(std::slice::from_ref(&patch), now);
5315
5316 let (_id, stored) = tracker.peer_db.get(&peer.stable_id).expect("still a peer");
5317 assert!(!stored.expired, "the extension un-expires the peer");
5318 assert_eq!(stored.node_key, peer.node_key, "its real node key is back");
5319 assert_eq!(
5320 stored.underlay_addresses, peer.underlay_addresses,
5321 "and its direct-path candidates"
5322 );
5323 assert_eq!(
5324 stored.derp_region, peer.derp_region,
5325 "and its home DERP route"
5326 );
5327 assert_eq!(
5328 stored.peerapi_addr(),
5329 peer.peerapi_addr(),
5330 "so a peerAPI dial to it is answerable again"
5331 );
5332 }
5333
5334 /// The lookup a caller holding an older [`Node`] snapshot refreshes it through, end to end
5335 /// through the live actor: `tailscale::Device::send_file` re-reads the peer by stable id so it
5336 /// refuses an expired peer with the reason instead of dialing a broken one and reporting a
5337 /// timeout. Also pins that the query answers *immediately* before the first netmap — queueing
5338 /// it (as [`PeerByName`] does) would park a send behind a netmap that may never arrive.
5339 #[tokio::test]
5340 async fn peer_by_stable_id_answers_with_the_current_flagged_record() {
5341 let env = test_env();
5342 let (_tka_tx, tka_rx) = watch::channel(None);
5343 let live = PeerTracker::spawn((env.clone(), tka_rx));
5344
5345 let peer = expiring_peer("eXpIrEd", 7, Some(local_now() - TimeDelta::hours(1)));
5346
5347 // Before any netmap: an immediate `None`, not a queued reply.
5348 let unknown = tokio::time::timeout(
5349 std::time::Duration::from_secs(5),
5350 live.ask(PeerByStableId {
5351 stable_id: peer.stable_id.clone(),
5352 }),
5353 )
5354 .await
5355 .expect("the query answers without waiting for a netmap")
5356 .expect("peer tracker is alive");
5357 assert_eq!(unknown, None);
5358
5359 env.publish(Arc::new(netmap_with_peers(vec![peer.clone()])))
5360 .await
5361 .expect("publish netmap");
5362 await_peer_count(&live, 1).await;
5363
5364 let current = live
5365 .ask(PeerByStableId {
5366 stable_id: peer.stable_id.clone(),
5367 })
5368 .await
5369 .expect("peer tracker is alive")
5370 .expect("the expired peer is kept, so it is still resolvable by stable id");
5371 assert!(
5372 current.expired,
5373 "the record carries the flag, not the stale state"
5374 );
5375 assert_eq!(
5376 current.peerapi_addr(),
5377 None,
5378 "so a peerAPI dial resolved from it is refused"
5379 );
5380 }
5381
5382 /// `MapResponse.ControlTime` is the reason the comparison is exact rather than approximate: a
5383 /// node whose own clock is hours behind control's must still see a peer as expired.
5384 #[tokio::test]
5385 async fn a_control_time_delta_decides_expiry_against_controls_clock() {
5386 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
5387 let now = local_now();
5388 // Local time says this expires in an hour.
5389 let peer = expiring_peer("sKeWeD", 5, Some(now + TimeDelta::hours(1)));
5390
5391 // Control's clock is two hours ahead of ours, so by control's reckoning it went an hour ago.
5392 tracker
5393 .expiry
5394 .on_control_time(now + TimeDelta::hours(2), now);
5395 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]), now);
5396
5397 let (_id, stored) = tracker.peer_db.get(&peer.stable_id).expect("kept");
5398 assert!(
5399 stored.expired,
5400 "expiry is judged against control's clock, not this host's"
5401 );
5402 }
5403}