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