ts_runtime/peer_tracker/mod.rs
1//! Peer delta update tracking.
2
3use std::{
4 collections::{HashMap, HashSet},
5 net::IpAddr,
6 sync::Arc,
7};
8
9use kameo::{
10 actor::ActorRef,
11 message::{Context, Message},
12 reply::ReplySender,
13};
14use tokio::sync::watch;
15use ts_control::{Node, UserId, UserProfile};
16use ts_keys::{DiscoPublicKey, NodePublicKey};
17use ts_transport::PeerId;
18
19use crate::{Error, dataplane::PeerDiscoKeyAdvertisement, env::Env, status::StatusNode};
20
21mod peer_db;
22
23pub use peer_db::PeerDb;
24
25/// Whether `key` is the all-zero disco key, which Go spells `key.DiscoPublic.IsZero()` and treats
26/// everywhere as "this peer has no disco key" rather than as a usable key.
27fn disco_key_is_zero(key: &DiscoPublicKey) -> bool {
28 key.to_bytes() == [0u8; DiscoPublicKey::KEY_LEN_BYTES]
29}
30
31/// Normalize a disco key as it arrives from control: the all-zero key means "absent", exactly as
32/// Go's `IsZero()` checks in `endpoint.updateDiscoKey` read it.
33fn disco_key_from_control(key: Option<DiscoPublicKey>) -> Option<DiscoPublicKey> {
34 key.filter(|k| !disco_key_is_zero(k))
35}
36
37/// The two disco keys a peer can present, and which of them is currently active — Go
38/// [`magicsock.endpointDisco`] (`wgengine/magicsock/endpoint.go`).
39///
40/// A peer's disco key reaches us from two independent sources: **control**, in a netmap node or a
41/// `PeersChangedPatch`, and the **peer itself**, in a TSMP disco-key advertisement carried inside
42/// the WireGuard tunnel. Go keeps both side by side on the endpoint, and so do we, because control
43/// is the slower of the two: an advertisement exists precisely to cover the window where control has
44/// not caught up with the peer's current key, so collapsing the two into one field would let the
45/// next map poll overwrite a freshly-learned key with control's stale one — losing the feature's own
46/// motivating case.
47///
48/// Only one key is active for sending at a time ([`key`](Self::key)). That active key is what the
49/// peer db carries in [`Node::disco_key`], which is this fork's live lookup for every direct-path
50/// consumer (`direct::DiscoPeerLookup` resolves against it, and `PeerDb`'s disco index is built from
51/// it) — the stand-in for Go's per-endpoint `disco` pointer.
52///
53/// [`magicsock.endpointDisco`]: https://github.com/tailscale/tailscale/blob/49e148c4a30b4f8098f69468fd27a7021d85ea02/wgengine/magicsock/endpoint.go
54#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
55struct EndpointDisco {
56 /// The key learned from control (Go `endpointDisco.controlKey`).
57 control: Option<DiscoPublicKey>,
58 /// The key learned from a TSMP advertisement (Go `endpointDisco.tsmpKey`).
59 tsmp: Option<DiscoPublicKey>,
60 /// Whether [`tsmp`](Self::tsmp) is the active key (Go `endpointDisco.tsmpActive`).
61 tsmp_active: bool,
62}
63
64impl EndpointDisco {
65 /// The key currently regarded as active — Go `endpointDisco.key()`.
66 fn key(&self) -> Option<DiscoPublicKey> {
67 if self.tsmp_active {
68 self.tsmp
69 } else {
70 self.control
71 }
72 }
73
74 /// The control-learned key, active or not — Go `endpointDisco.keyFromControl()`.
75 fn key_from_control(&self) -> Option<DiscoPublicKey> {
76 self.control
77 }
78
79 /// The TSMP-learned key, active or not — Go `endpointDisco.keyFromTSMP()`.
80 fn key_from_tsmp(&self) -> Option<DiscoPublicKey> {
81 self.tsmp
82 }
83
84 /// Replace the control-learned key, leaving any TSMP-learned key in place — Go
85 /// `endpoint.updateDiscoKey`.
86 ///
87 /// A non-zero control key takes the active slot (control has caught up, so it is authoritative
88 /// again); an absent one hands the slot back to the TSMP key, if there is one.
89 fn update_from_control(&mut self, key: Option<DiscoPublicKey>) {
90 self.control = key;
91 self.tsmp_active = key.is_none();
92 }
93
94 /// Replace the TSMP-learned key, leaving the control-learned key in place — Go
95 /// `endpoint.updateTSMPDiscoKey`.
96 fn update_from_tsmp(&mut self, key: Option<DiscoPublicKey>) {
97 self.tsmp = key;
98 self.tsmp_active = key.is_some();
99 }
100
101 /// No key material from either source — Go nils out the endpoint's `disco` pointer here.
102 fn is_empty(&self) -> bool {
103 self.control.is_none() && self.tsmp.is_none()
104 }
105}
106
107/// Actor that tracks peer delta updates and emits new states.
108pub struct PeerTracker {
109 peer_db: PeerDb,
110 seen_state_update: bool,
111 pending_requests: Vec<Pending>,
112 /// Latest peer snapshot, published on every netmap update so embedders can watch for peer
113 /// changes ([`WatchNetmap`]).
114 peer_watch: watch::Sender<Vec<StatusNode>>,
115 /// Accumulated netmap user profiles (`MapResponse.UserProfiles`), keyed by user id, joined
116 /// against a node's [`Node::user_id`](ts_control::Node::user_id) to resolve the owning user's
117 /// login/display name for a [`WhoIs`](crate::status::WhoIs). Control sends these incrementally
118 /// (only new/changed profiles per response), so this map **accumulates** across updates rather
119 /// than being replaced — a peer upserted in one response may reference a profile delivered in an
120 /// earlier one.
121 user_profiles: HashMap<UserId, UserProfile>,
122 /// Per-peer disco-key provenance ([`EndpointDisco`]), keyed by the peer's node key.
123 ///
124 /// Go keeps this on the magicsock `endpoint`, which the peer map keys by node key; here the peer
125 /// db stores control's [`Node`] verbatim, so the second key (and which of the two is active)
126 /// lives beside it. Keying by node key reproduces Go's lifetime exactly: the state is dropped
127 /// when the peer leaves the netmap, and a peer that ROTATES its node key gets a fresh entry —
128 /// Go builds it a new endpoint, so a key learned over TSMP under the old node key is never
129 /// carried onto the new one. [`prune_endpoint_disco`](PeerTracker::prune_endpoint_disco) does
130 /// the dropping.
131 endpoint_disco: HashMap<NodePublicKey, EndpointDisco>,
132 /// Tailnet-Lock (TKA) authority enforced at the peer-trust chokepoint, matching Go
133 /// `tkaFilterNetmapLocked`. Read on demand from a [`watch`] cell the control runner owns: when it
134 /// holds `Some` (a verified lock has been synced from control), enforcement is **active** — every
135 /// upserted peer must present a `key_signature` this authority authorizes, or it is dropped
136 /// (fail-closed), exactly as Go drops peers with a missing or failing signature. When it holds
137 /// `None` (no lock, or the lock was disabled) enforcement is **inactive** and every peer is
138 /// upserted, identical to pre-TKA behavior and to Go's `b.tka == nil` early return.
139 ///
140 /// A `watch::Receiver` (not the bus) is the transport on purpose: the authority is a single
141 /// security-critical state cell, and `watch` is last-write-wins, never-dropped, and ordered by
142 /// the control runner's own writes — so a disable (`None`) can never be reordered behind or
143 /// silently dropped before a stale `Some` (which a best-effort broadcast bus could do, leaving a
144 /// defunct lock enforcing forever). The control runner is the sole writer; we only ever read.
145 ///
146 /// The authority always passes through `VerifiedAumChain::verify` before the control runner
147 /// publishes it, so enforcement only engages on a chain we have cryptographically verified.
148 /// Connectivity now depends on `ts_tka` verifying genuinely-good signatures correctly (see
149 /// SECURITY.md). Self is structurally never filtered here (the self node never enters `peer_db` —
150 /// it is routed to the control runner's `self_node` cell), so a node cannot lock itself out of
151 /// its own netmap.
152 tka_authority: watch::Receiver<Option<Arc<ts_tka::Authority>>>,
153 env: Env,
154}
155
156impl PeerTracker {
157 fn peer_by_name_opt(&self, name: &str) -> Option<&Node> {
158 // Canonicalization (case + trailing dot) is handled inside the name index lookup.
159 self.peer_db.get(&name).map(|(_id, node)| node)
160 }
161
162 fn peer_by_tailnet_ip_opt(&self, ip: IpAddr) -> Option<&Node> {
163 self.peer_db.get(&ip).map(|(_id, node)| node)
164 }
165
166 /// Build the peer entries for a [`Status`](crate::Status) snapshot from the current peer db.
167 ///
168 /// Connectivity fields (`cur_addr`/`relay`) are left at their `from_node` defaults (`None`) here:
169 /// this is the live-watch/hot path and must stay magicsock-free and synchronous. The explicit
170 /// [`GetStatus`] snapshot enriches them ([`status_peers_with_ids`](Self::status_peers_with_ids)).
171 fn status_peers(&self) -> Vec<StatusNode> {
172 self.peer_db
173 .peers()
174 .values()
175 .map(StatusNode::from_node)
176 .collect()
177 }
178
179 /// Like [`status_peers`](Self::status_peers) but pairs each entry with its [`PeerId`], so the
180 /// caller can join per-peer connectivity (the direct manager's `best_addrs`, keyed by `PeerId`)
181 /// onto the `StatusNode` before returning it. Order is unspecified (a `HashMap` walk).
182 fn status_peers_with_ids(&self) -> Vec<(PeerId, StatusNode)> {
183 self.peer_db
184 .peers()
185 .iter()
186 .map(|(id, node)| (*id, StatusNode::from_node(node)))
187 .collect()
188 }
189
190 fn whois_opt(&self, addr: std::net::SocketAddr) -> Option<crate::status::WhoIs> {
191 let ip = crate::status::whois_addr(addr);
192 let node = self.peer_by_tailnet_ip_opt(ip).cloned()?;
193 // Join the node's owning user id against the accumulated UserProfiles table to resolve a
194 // login/display name. `None` when control sent no profile for that user (e.g. tagged nodes
195 // with no human owner, or a profile not yet delivered).
196 let user = self.resolve_user(node.user_id);
197 Some(crate::status::WhoIs::from_node_with_user(node, user))
198 }
199
200 /// Resolve a user id to its best display label from the accumulated profile table.
201 fn resolve_user(&self, user_id: UserId) -> Option<String> {
202 self.user_profiles
203 .get(&user_id)
204 .and_then(UserProfile::best_label)
205 }
206
207 /// Whether `node` may be admitted to the peer db under Tailnet Lock, matching Go
208 /// `tkaFilterNetmapLocked`'s per-peer verdict (drop unsigned / failed-signature peers).
209 ///
210 /// This consults the live [`tka_authority`](Self::tka_authority) cell on each call (one `borrow`,
211 /// held only for the duration of the verdict). For a `Full` resync — which checks every peer —
212 /// prefer [`tka_authority_snapshot`](Self::tka_authority_snapshot) +
213 /// [`tka_snapshot_admits`](Self::tka_snapshot_admits) to borrow once and verify each peer a single
214 /// time; this method is the convenience wrapper for the single-peer (`Delta`/patch) sites.
215 ///
216 /// Fail-closed and gated:
217 /// - No authority ⇒ no lock synced ⇒ always admit (Go's `b.tka == nil` early return; identical to
218 /// pre-TKA behavior).
219 /// - **Empty trusted-key state** ⇒ always admit (logged at `error!` — see
220 /// [`tka_snapshot_admits`](Self::tka_snapshot_admits) for the full rationale).
221 /// - Authority present + peer carries a `key_signature` the authority authorizes for the peer's
222 /// node key ⇒ admit.
223 /// - Authority present + signature missing or unauthorized/invalid ⇒ **drop** (Go drops peers
224 /// with a missing signature or failed `NodeKeyAuthorized` under tailnet lock).
225 fn tka_admits(&self, node: &Node) -> bool {
226 // Single-peer sites (`Delta`/patch) only need the admit bool; the rotation details are used
227 // exclusively by the cross-peer `Full` filter (rotation obsolescence is whole-netmap).
228 Self::tka_snapshot_admits(self.tka_authority.borrow().as_deref(), node).admitted
229 }
230
231 /// Borrow the current TKA authority once (cloning the cheap `Arc`) for a batch verdict. Returns
232 /// `None` when no lock is synced (admit-all). Used by the `Full` path so a netmap of N peers
233 /// reads the cell once and runs at most one signature verify per peer (not two).
234 fn tka_authority_snapshot(&self) -> Option<Arc<ts_tka::Authority>> {
235 self.tka_authority.borrow().clone()
236 }
237
238 /// The per-peer Tailnet-Lock verdict against an already-borrowed `authority` snapshot. Factored
239 /// out so both the single-peer [`tka_admits`](Self::tka_admits) and the `Full` batch path share
240 /// one verdict implementation (no divergence) while the batch path verifies each peer exactly
241 /// once.
242 ///
243 /// Returns whether the peer is admitted AND, for an admitted peer signed by a rotation chain, the
244 /// [`RotationDetails`](ts_tka::RotationDetails) of that chain — so the `Full` path can run the
245 /// cross-peer rotation filter (Go's `rotationTracker`) without a second verify per peer. A peer
246 /// that is dropped, unsigned, or signed by a non-rotation chain carries `rotation == None`.
247 ///
248 /// Never logs key/signature bytes — only the `stable_id` and the `TkaError` Display (static
249 /// descriptors). One documented parity gap remains vs Go (under-enforcement, in PARITY_ROADMAP):
250 /// no `UnsignedPeerAPIOnly` exemption (our node model lacks the field).
251 fn tka_snapshot_admits(authority: Option<&ts_tka::Authority>, node: &Node) -> TkaVerdict {
252 let Some(auth) = authority else {
253 return TkaVerdict::admit();
254 };
255
256 // Brick-guard: an authority with no trusted keys would drop every peer. A verified chain is
257 // structurally guaranteed ≥1 key (genesis rejects an empty key set, and the last key cannot
258 // be removed), so reaching here means a `ts_tka` invariant was violated — admit rather than
259 // black-hole the whole netmap, and log at `error!` because it signals a real bug, not an
260 // expected runtime input. This is OUR fail-safe, not a Go behavior. NOTE: it only catches the
261 // empty-keyset shape; a non-empty authority that authorizes none of the offered peers still
262 // (correctly) drops them — that is what a lock that revoked everyone means. The
263 // "authorized-zero-peers" isolation case is surfaced separately by the caller.
264 if auth.state().keys.is_empty() {
265 tracing::error!(
266 "TKA: authority has an empty trusted-key set (verified chains never do — likely a \
267 ts_tka bug); not enforcing (admitting all) to avoid isolating the node"
268 );
269 return TkaVerdict::admit();
270 }
271
272 if node.key_signature.is_empty() {
273 tracing::warn!(
274 stable_id = ?node.stable_id,
275 "TKA: dropping unsigned peer under tailnet lock"
276 );
277 return TkaVerdict::drop();
278 }
279
280 match auth.node_key_authorized_with_details(&node.node_key.to_bytes(), &node.key_signature)
281 {
282 Ok(rotation) => {
283 tracing::debug!(stable_id = ?node.stable_id, "TKA: peer node-key authorized");
284 TkaVerdict {
285 admitted: true,
286 rotation,
287 }
288 }
289 Err(e) => {
290 tracing::warn!(
291 stable_id = ?node.stable_id,
292 error = %e,
293 "TKA: dropping peer with unauthorized node key"
294 );
295 TkaVerdict::drop()
296 }
297 }
298 }
299
300 /// The **keep** verdict for a whole batch of peers under `authority` — one complete Go
301 /// `tkaFilterNetmapLocked` pass (`ipn/ipnlocal/tailnet-lock.go`, v1.100.0), in Go's order:
302 ///
303 /// 1. the per-peer signature verdict ([`tka_snapshot_admits`](Self::tka_snapshot_admits)), then
304 /// 2. the cross-peer rotation filter (Go `rotationTracker`): a peer presenting a node key that a
305 /// newer rotation has superseded — or a tied clone of one — is dropped even though its own
306 /// signature verifies. That is whole-batch by nature (one peer's chain obsoletes another's
307 /// key), which is why it lives here and not in the per-peer verdict.
308 ///
309 /// Factored out because two call sites must agree exactly on what "admitted" means: the `Full`
310 /// netmap upsert in [`apply_peer_update`](Self::apply_peer_update), and
311 /// [`tka_reevaluate_peer_db`](Self::tka_reevaluate_peer_db), which re-runs the same pass over the
312 /// peers already in the db when a freshly-synced authority is installed. A divergence between
313 /// them would be a peer admitted by one path and dropped by the other.
314 ///
315 /// `authority` is borrowed once and each peer verified exactly once (the ed25519 verify is the
316 /// expensive part). Returns one `bool` per input node, in input order; `None` authority ⇒ all
317 /// `true` (no lock synced ⇒ admit all, Go's `b.tka == nil` early return).
318 fn tka_keep_verdicts(authority: Option<&ts_tka::Authority>, nodes: &[&Node]) -> Vec<bool> {
319 let verdicts = nodes
320 .iter()
321 .map(|node| Self::tka_snapshot_admits(authority, node))
322 .collect::<Vec<_>>();
323
324 let mut rotation = RotationTracker::default();
325 for (node, verdict) in nodes.iter().zip(&verdicts) {
326 if verdict.admitted
327 && let Some(details) = &verdict.rotation
328 {
329 rotation.add(node.node_key.to_bytes().to_vec(), details);
330 }
331 }
332 let obsolete = rotation.obsolete_keys();
333
334 nodes
335 .iter()
336 .zip(&verdicts)
337 .map(|(node, v)| {
338 // `contains` takes `&[u8]` (HashSet<Vec<u8>> borrows as a slice) — no alloc.
339 v.admitted && !obsolete.contains(&node.node_key.to_bytes()[..])
340 })
341 .collect()
342 }
343}
344
345/// The outcome of a per-peer Tailnet-Lock check: whether the peer is admitted, plus (for an admitted
346/// peer signed by a rotation chain) the chain's [`RotationDetails`](ts_tka::RotationDetails) so the
347/// `Full` path can run the cross-peer rotation filter from the SAME verify pass (no second verify).
348struct TkaVerdict {
349 admitted: bool,
350 rotation: Option<ts_tka::RotationDetails>,
351}
352
353impl TkaVerdict {
354 /// Admitted, no rotation details (no lock / brick-guard / non-rotation signature).
355 fn admit() -> Self {
356 Self {
357 admitted: true,
358 rotation: None,
359 }
360 }
361 /// Dropped.
362 fn drop() -> Self {
363 Self {
364 admitted: false,
365 rotation: None,
366 }
367 }
368}
369
370/// Cross-peer rotation-obsolescence tracker, mirroring Go `ipnlocal.rotationTracker`. Fed the
371/// [`RotationDetails`](ts_tka::RotationDetails) of every admitted, rotation-signed peer in a `Full`
372/// netmap; [`obsolete_keys`](Self::obsolete_keys) then returns the node keys to drop on top of the
373/// per-peer verdict. Two rules (Go `tkaFilterNetmapLocked` + `rotationTracker.obsoleteKeys`):
374///
375/// 1. Every prior node key named in any rotation chain is obsolete (a newer chain rotated it away).
376/// 2. Among `Direct`-rooted chains sharing one wrapping pubkey (a clone signal), only the
377/// longest-chain peer survives; if the two longest are tied, ALL in that group are dropped (we
378/// cannot tell which is the latest, so reject for safety). `Credential`-rooted chains are exempt
379/// from rule 2 — several nodes can legitimately join under one reusable auth key (same wrapping
380/// pubkey), so sharing it is not a clone signal there. (Rule 1 still applies to them.)
381///
382/// Node keys are tracked as raw `Vec<u8>` (the verified 32-byte node-public bytes).
383#[derive(Default)]
384struct RotationTracker {
385 obsolete: HashSet<Vec<u8>>,
386 by_wrapping_key: HashMap<Vec<u8>, Vec<SigRotation>>,
387}
388
389/// One admitted peer's rotation entry within a wrapping-key group.
390struct SigRotation {
391 node_key: Vec<u8>,
392 num_prev_keys: usize,
393}
394
395impl RotationTracker {
396 /// Record an admitted peer `node_key` and its rotation `details` (Go `addRotationDetails`).
397 fn add(&mut self, node_key: Vec<u8>, details: &ts_tka::RotationDetails) {
398 // Rule 1: every prior key is obsolete — applied for ALL chains (incl. credential-rooted),
399 // matching Go's ungated `obsolete.AddSlice(d.PrevNodeKeys)`.
400 self.obsolete.extend(details.prev_node_keys.iter().cloned());
401 // Rule 2 (clone-uniqueness) is gated to Direct-rooted chains only.
402 if details.initial_sig_kind != ts_tka::SigKind::Direct {
403 return;
404 }
405 self.by_wrapping_key
406 .entry(details.initial_wrapping_pubkey.clone())
407 .or_default()
408 .push(SigRotation {
409 node_key,
410 num_prev_keys: details.prev_node_keys.len(),
411 });
412 }
413
414 /// Compute the full obsolete node-key set (Go `rotationTracker.obsoleteKeys`). Processes each
415 /// wrapping-key group, mutating the shared `obsolete` set as it goes (so a key obsoleted by one
416 /// group is seen as obsolete by later groups via the `retain` below — Go's
417 /// `slices.DeleteFunc(... Contains)`). Group iteration order (a `HashMap` drain) is
418 /// nondeterministic, but the result is order-INDEPENDENT: this only ever *inserts* into
419 /// `obsolete` (never removes), and rule 1 already obsoleted every prior key before this loop, so
420 /// the final set is a union that does not depend on which group runs first (as in Go).
421 fn obsolete_keys(mut self) -> HashSet<Vec<u8>> {
422 // Drain only the group map so the loop can mutate `self.obsolete` without aliasing it; the
423 // shared `obsolete` set itself is NOT drained, preserving the cross-group visibility above.
424 let groups: Vec<Vec<SigRotation>> = self.by_wrapping_key.drain().map(|(_k, v)| v).collect();
425 for mut group in groups {
426 // Drop entries already obsoleted (rotated away) by another chain.
427 group.retain(|rd| !self.obsolete.contains(&rd.node_key));
428 if group.is_empty() {
429 continue;
430 }
431 // Longest chain (most prior keys) is the newest ⇒ the survivor; sort decreasing.
432 // `sort_by_key` is stable (like Go's `SortStableFunc`); `Reverse` gives descending order.
433 group.sort_by_key(|rd| core::cmp::Reverse(rd.num_prev_keys));
434 if group.len() >= 2 && group[0].num_prev_keys == group[1].num_prev_keys {
435 // Tie for longest ⇒ cannot disambiguate the latest ⇒ drop the WHOLE group.
436 tracing::warn!(
437 "TKA: multiple peers share a wrapping key with equal rotation depth; dropping all (cannot determine the latest)"
438 );
439 for rd in &group {
440 self.obsolete.insert(rd.node_key.clone());
441 }
442 } else {
443 // Only the longest-chain peer survives; the rest are obsolete.
444 for rd in &group[1..] {
445 self.obsolete.insert(rd.node_key.clone());
446 }
447 }
448 }
449 self.obsolete
450 }
451}
452
453impl kameo::Actor for PeerTracker {
454 /// `(env, tka_authority)`: the bus/keys env, plus the read end of the control runner's TKA
455 /// enforcement-authority cell (Go `tkaFilterNetmapLocked`). The control runner is the sole
456 /// writer; it publishes the verified `Authority` after a successful `/machine/tka/sync` and
457 /// `None` when the lock is disabled. A `watch` cell (not a bus message) so the latest value is
458 /// always readable on demand, never dropped, and never reordered (see the control runner's
459 /// `tka_authority` cell).
460 type Args = (Env, watch::Receiver<Option<Arc<ts_tka::Authority>>>);
461 type Error = Error;
462
463 async fn on_start(
464 (env, tka_authority): Self::Args,
465 slf: ActorRef<Self>,
466 ) -> Result<Self, Self::Error> {
467 env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
468 env.subscribe::<PeerDiscoKeyAdvertisement>(&slf).await?;
469
470 // Re-filter the peer db whenever the enforcement authority changes. Go gets this for free:
471 // `SetControlClientStatus` runs `tkaSyncIfNeeded` and `tkaFilterNetmapLocked` back to back
472 // over one netmap. Here the sync is asynchronous, so the peers admitted before the authority
473 // arrived need a second pass — see `tka_reevaluate_peer_db`. `changed()` resolves on every
474 // write to the cell (enable, re-sync, disable); the task ends when the control runner drops
475 // the sender (shutdown) or the tracker itself is gone.
476 //
477 // A **weak** ref on purpose: the runtime holds only a `WeakActorRef` to the peer tracker, so
478 // a strong one parked in this task would keep the actor's mailbox alive past shutdown.
479 let mut authority_changes = tka_authority.clone();
480 let notify = slf.downgrade();
481 tokio::spawn(async move {
482 while authority_changes.changed().await.is_ok() {
483 let Some(tracker) = notify.upgrade() else {
484 break; // the peer tracker is gone; nothing left to re-filter
485 };
486 if tracker.tell(TkaAuthorityChanged).await.is_err() {
487 break; // the peer tracker stopped
488 }
489 }
490 });
491
492 let (peer_watch, _) = watch::channel(Vec::new());
493
494 Ok(Self {
495 peer_db: PeerDb::default(),
496 pending_requests: Default::default(),
497 seen_state_update: false,
498 peer_watch,
499 user_profiles: HashMap::new(),
500 endpoint_disco: HashMap::new(),
501 // The cell starts `None` (no lock synced ⇒ enforcement inactive, admit all, matching
502 // Go's `b.tka == nil`); the control runner flips it to `Some` on the first sync.
503 tka_authority,
504 env,
505 })
506 }
507}
508
509enum Pending {
510 PeerByName(PeerByName, ReplySender<Option<Node>>),
511 AcceptedRoute(PeerByAcceptedRoute, ReplySender<Vec<Node>>),
512 TailnetIp(PeerByTailnetIp, ReplySender<Option<Node>>),
513 Status(ReplySender<Vec<(PeerId, StatusNode)>>),
514 WhoIs(Whois, ReplySender<Option<crate::status::WhoIs>>),
515}
516
517// For messages with arguments, a struct is generated with the args as fields. They aren't
518// documented, and we can't apply attributes directly to the fields. Hence, wrap in a module where
519// docs are turned off everywhere.
520#[allow(missing_docs)]
521mod msg_impl {
522 use std::net::IpAddr;
523
524 use kameo::prelude::DelegatedReply;
525
526 use super::*;
527
528 #[kameo::messages]
529 impl PeerTracker {
530 /// Lookup a peer by name.
531 ///
532 /// Waits until we've received at least one peer update from control.
533 #[message(ctx)]
534 pub async fn peer_by_name(
535 &mut self,
536 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
537 name: String,
538 ) -> DelegatedReply<Option<Node>> {
539 let (deleg, sender) = ctx.reply_sender();
540 let Some(sender) = sender else { return deleg };
541
542 if !self.seen_state_update {
543 tracing::debug!(query = name, "no peer state seen yet, queueing request");
544
545 self.pending_requests
546 .push(Pending::PeerByName(PeerByName { name }, sender));
547
548 return deleg;
549 }
550
551 sender.send(self.peer_by_name_opt(&name).cloned());
552
553 deleg
554 }
555
556 /// Lookup all peers that accept packets addressed to the given IP.
557 ///
558 /// This includes the peer's tailnet address and any subnet routes it provides. Only
559 /// the peers with the most specific subnet route match that covers `ip` will be
560 /// returned.
561 ///
562 /// E.g., suppose:
563 ///
564 /// - We're querying for `10.1.2.3`
565 /// - `PeerA` and `PeerB` have accepted routes for `10.1.2.0/24`
566 /// - `PeerC` has an accepted route for `10.1.0.0/16`
567 ///
568 /// Only `PeerA` and `PeerB` will be returned, since they have the most specific
569 /// prefix match.
570 #[message(ctx)]
571 pub fn peer_by_accepted_route(
572 &mut self,
573 ctx: &mut Context<Self, DelegatedReply<Vec<Node>>>,
574 ip: IpAddr,
575 ) -> DelegatedReply<Vec<Node>> {
576 let (deleg, sender) = ctx.reply_sender();
577 let Some(sender) = sender else { return deleg };
578
579 if !self.seen_state_update {
580 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
581
582 self.pending_requests
583 .push(Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, sender));
584
585 return deleg;
586 }
587
588 sender.send(
589 self.peer_db
590 .get_route(ip.into())
591 .map(|(_id, node)| node.clone())
592 .collect(),
593 );
594
595 deleg
596 }
597
598 /// Lookup the peer that has the given tailnet IP address.
599 #[message(ctx)]
600 pub fn peer_by_tailnet_ip(
601 &mut self,
602 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
603 ip: IpAddr,
604 ) -> DelegatedReply<Option<Node>> {
605 let (deleg, sender) = ctx.reply_sender();
606 let Some(sender) = sender else { return deleg };
607
608 if !self.seen_state_update {
609 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
610
611 self.pending_requests
612 .push(Pending::TailnetIp(PeerByTailnetIp { ip }, sender));
613
614 return deleg;
615 }
616
617 sender.send(self.peer_by_tailnet_ip_opt(ip).cloned());
618
619 deleg
620 }
621
622 /// Build the peer entries of a [`Status`](crate::Status) snapshot, each paired with its
623 /// [`PeerId`] so [`Runtime::status`](crate::Runtime::status) can join per-peer connectivity
624 /// (`cur_addr`/`relay`) from the direct manager before returning. The self node is *not*
625 /// included here (it lives in the control runner); `Runtime::status` combines both and drops
626 /// the ids.
627 ///
628 /// Waits until we've received at least one peer update from control.
629 #[message(ctx)]
630 pub fn get_status(
631 &mut self,
632 ctx: &mut Context<Self, DelegatedReply<Vec<(PeerId, StatusNode)>>>,
633 ) -> DelegatedReply<Vec<(PeerId, StatusNode)>> {
634 let (deleg, sender) = ctx.reply_sender();
635 let Some(sender) = sender else { return deleg };
636
637 if !self.seen_state_update {
638 tracing::debug!("no peer state seen yet, queueing status request");
639 self.pending_requests.push(Pending::Status(sender));
640 return deleg;
641 }
642
643 sender.send(self.status_peers_with_ids());
644
645 deleg
646 }
647
648 /// Return every known peer's full domain [`Node`] (not the lossy [`StatusNode`]).
649 ///
650 /// Used by [`Runtime::file_targets`](crate::Runtime::file_targets), which needs the full node
651 /// (peerAPI address, owning user id, cap map) to compute Taildrop send targets. The self node
652 /// is not included (it lives in the control runner). Returns empty before the first netmap —
653 /// the natural "not connected yet" analog (an immediate answer, no queueing needed: callers
654 /// that need a populated list await `Running` first).
655 #[message]
656 pub fn all_peers(&self) -> Vec<Node> {
657 self.peer_db.peers().values().cloned().collect()
658 }
659
660 /// Resolve which node owns a tailnet source address.
661 ///
662 /// Maps the source IP of `addr` to the owning node via the tailnet-IP index, returning a
663 /// [`WhoIs`](crate::WhoIs). The port is ignored (a tailnet IP uniquely identifies a node).
664 ///
665 /// The resulting [`WhoIs`](crate::WhoIs) carries no user/login or capability data: this
666 /// fork's domain [`Node`] does not retain those wire fields. See the
667 /// [`status`](crate::status) module docs for the gap.
668 ///
669 /// Waits until we've received at least one peer update from control.
670 #[message(ctx)]
671 pub fn whois(
672 &mut self,
673 ctx: &mut Context<Self, DelegatedReply<Option<crate::status::WhoIs>>>,
674 addr: std::net::SocketAddr,
675 ) -> DelegatedReply<Option<crate::status::WhoIs>> {
676 let (deleg, sender) = ctx.reply_sender();
677 let Some(sender) = sender else { return deleg };
678
679 if !self.seen_state_update {
680 tracing::debug!(query = %addr, "no peer state seen yet, queueing whois request");
681 self.pending_requests
682 .push(Pending::WhoIs(Whois { addr }, sender));
683 return deleg;
684 }
685
686 sender.send(self.whois_opt(addr));
687
688 deleg
689 }
690
691 /// Subscribe to netmap peer-change events.
692 ///
693 /// Returns a [`watch::Receiver`] whose value is the current set of peer
694 /// [`StatusNode`]s, updated on every netmap state update from control. Embedders can await
695 /// changes via [`watch::Receiver::changed`] to react to peers joining, leaving, or changing.
696 ///
697 /// The receiver's initial value is the peer set at subscription time (empty before the
698 /// first netmap update). This is a peer-only view; combine with the self node from
699 /// [`Runtime::status`](crate::Runtime::status) when a full snapshot is needed.
700 #[message(derive(Clone))]
701 pub fn watch_netmap(&self) -> watch::Receiver<Vec<StatusNode>> {
702 self.peer_watch.subscribe()
703 }
704 }
705}
706
707pub use msg_impl::*;
708
709#[derive(Debug, Clone)]
710pub(crate) struct PeerState {
711 #[allow(unused)]
712 pub deletions: HashSet<PeerId>,
713 #[allow(unused)]
714 pub upserts: HashSet<PeerId>,
715 pub peers: Arc<PeerDb>,
716}
717
718impl Message<Arc<ts_control::StateUpdate>> for PeerTracker {
719 type Reply = ();
720
721 async fn handle(
722 &mut self,
723 msg: Arc<ts_control::StateUpdate>,
724 _ctx: &mut Context<Self, Self::Reply>,
725 ) {
726 // Accumulate user profiles first — control sends them incrementally and a response may
727 // carry profiles with no peer delta (or peers that reference a profile from an earlier
728 // response), so this must happen before the no-peer-update early return below.
729 for profile in &msg.user_profiles {
730 self.user_profiles.insert(profile.id, profile.clone());
731 }
732
733 // Apply the standalone online/last-seen delta maps (channels C/D, `MapResponse.OnlineChange`
734 // / `PeerSeenChange`). These arrive keyed by control node id and may ride a response that
735 // carries NO `peer_update` (a bare online flip is the common case), so they must be applied
736 // *before* the no-peer-update early return — otherwise online status freezes at the last
737 // full-node/patch value. Each entry only ever *sets* a value (never back to unknown).
738 // Wall clock for a `PeerSeenChange: true` (Go uses `clock.Now()`). chrono is built without
739 // its `clock` feature in this workspace, so derive it from `SystemTime` the same way the
740 // control runner / ssh-policy paths do (unix secs → `DateTime::from_timestamp`).
741 let now = std::time::SystemTime::now()
742 .duration_since(std::time::UNIX_EPOCH)
743 .ok()
744 .and_then(|d| chrono::DateTime::from_timestamp(d.as_secs() as i64, d.subsec_nanos()))
745 .unwrap_or_default();
746 let liveness_changed =
747 self.apply_liveness_changes(&msg.online_change, &msg.peer_seen_change, now);
748
749 if msg.peer_update.is_none() && msg.peer_patches.is_empty() {
750 // No peer set or patch this response. If a liveness delta still mutated the netmap,
751 // publish the refreshed snapshot so watchers (and `GetStatus`) see the new online state.
752 if liveness_changed {
753 self.service_pending_requests();
754 self.peer_watch.send_replace(self.status_peers());
755 if let Err(e) = self
756 .env
757 .publish(Arc::new(PeerState {
758 upserts: HashSet::default(),
759 deletions: HashSet::default(),
760 peers: Arc::new(self.peer_db.clone()),
761 }))
762 .await
763 {
764 tracing::error!(error = %e, "publishing liveness-only peer state update");
765 }
766 }
767 return;
768 }
769
770 // Apply the whole-node peer set (if any) FIRST, then the field-level patches on top —
771 // mirroring Go's `controlclient` order (`Peers*` then `PeersChangedPatch`). A response may
772 // carry either, both, or (with a liveness-only delta) neither. Merge the upsert/deletion sets
773 // so the published `PeerState` reflects every node touched by both passes; a node both
774 // upserted by the set and patched stays in `upserts` (the patch removes it from `deletions`).
775 let (mut upserts, mut deletions) = msg
776 .peer_update
777 .as_ref()
778 .map(|u| self.apply_peer_update(u))
779 .unwrap_or_default();
780
781 if !msg.peer_patches.is_empty() {
782 let (patch_upserts, patch_deletions) = self.apply_peer_patches(&msg.peer_patches);
783 // A patch can evict a node the set just upserted (TKA rejection after key rotation), or
784 // re-admit/patch one not in the set — reconcile so each id lands in exactly one set.
785 for id in &patch_upserts {
786 deletions.remove(id);
787 }
788 for id in &patch_deletions {
789 upserts.remove(id);
790 }
791 upserts.extend(patch_upserts);
792 deletions.extend(patch_deletions);
793 }
794
795 tracing::debug!(
796 n_upsert = upserts.len(),
797 n_delete = deletions.len(),
798 peer_count = self.peer_db.peers().len(),
799 "new peer state"
800 );
801
802 self.service_pending_requests();
803
804 // Publish the latest peer snapshot to netmap watchers. `send_replace` keeps the receiver's
805 // value current even when there are no subscribers, so a late subscriber sees fresh state.
806 self.peer_watch.send_replace(self.status_peers());
807
808 if let Err(e) = self
809 .env
810 .publish(Arc::new(PeerState {
811 upserts,
812 deletions,
813 peers: Arc::new(self.peer_db.clone()),
814 }))
815 .await
816 {
817 tracing::error!(error = %e, "publishing peer state update");
818 }
819 }
820}
821
822impl Message<PeerDiscoKeyAdvertisement> for PeerTracker {
823 type Reply = ();
824
825 async fn handle(
826 &mut self,
827 msg: PeerDiscoKeyAdvertisement,
828 _ctx: &mut Context<Self, Self::Reply>,
829 ) {
830 if !self.learn_disco_key(msg.peer, msg.key) {
831 return;
832 }
833
834 // The key changed, so republish: the direct-path machinery resolves a peer's disco key out
835 // of the published `PeerState` snapshot (`direct::DiscoPeerLookup`), which is the whole
836 // point of learning it — it is what lets disco reach this peer without waiting for a
837 // netmap update. Go does the equivalent by writing the key straight into the magicsock
838 // endpoint and re-keying its peer map.
839 self.peer_watch.send_replace(self.status_peers());
840
841 if let Err(e) = self
842 .env
843 .publish(Arc::new(PeerState {
844 upserts: HashSet::from_iter([msg.peer]),
845 deletions: HashSet::default(),
846 peers: Arc::new(self.peer_db.clone()),
847 }))
848 .await
849 {
850 tracing::error!(error = %e, "publishing peer state after a TSMP disco-key advertisement");
851 }
852 }
853}
854
855/// Internal self-message: the Tailnet-Lock enforcement-authority cell changed — the control runner
856/// installed a freshly-synced [`Authority`](ts_tka::Authority) after a `/machine/tka/sync`, or
857/// cleared it because the lock was disabled. Sent by the watch task
858/// [`on_start`](kameo::Actor::on_start) spawns, so the peer db is re-filtered the moment enforcement
859/// changes instead of at whatever later `Full` netmap happens to arrive.
860#[derive(Debug, Clone, Copy)]
861pub(crate) struct TkaAuthorityChanged;
862
863impl Message<TkaAuthorityChanged> for PeerTracker {
864 type Reply = ();
865
866 async fn handle(&mut self, _msg: TkaAuthorityChanged, _ctx: &mut Context<Self, Self::Reply>) {
867 let deletions = self.tka_reevaluate_peer_db();
868 if deletions.is_empty() {
869 // The common case: enforcement is inactive, or every admitted peer still verifies.
870 return;
871 }
872
873 // An evicted peer must lose its data path, not just its db row, so republish the snapshot
874 // the `Arc<PeerState>` subscribers (route updater, source filter, dataplane) resolve
875 // against — the same publish the netmap handler does after a peer set changes.
876 self.peer_watch.send_replace(self.status_peers());
877
878 if let Err(e) = self
879 .env
880 .publish(Arc::new(PeerState {
881 upserts: HashSet::default(),
882 deletions,
883 peers: Arc::new(self.peer_db.clone()),
884 }))
885 .await
886 {
887 tracing::error!(error = %e, "publishing peer state after a TKA authority change");
888 }
889 }
890}
891
892/// Ask the peer tracker to re-broadcast its current peer snapshot on the bus, without any peer
893/// change. Sent after a runtime preference change so the route updater and source filter (both
894/// `Arc<PeerState>` subscribers) re-resolve against the new value immediately, rather than waiting
895/// for the next netmap update: `Device::set_exit_node` (new exit-node selector) and
896/// `Device::set_accept_routes` (new accept-routes flag) both send it.
897#[derive(Debug, Clone, Copy)]
898pub struct RepublishState;
899
900impl Message<RepublishState> for PeerTracker {
901 type Reply = ();
902
903 async fn handle(&mut self, _msg: RepublishState, _ctx: &mut Context<Self, Self::Reply>) {
904 // An empty upsert/deletion set: this is a re-broadcast of the unchanged peer set, not a
905 // delta. Subscribers recompute their routes/filters against the current peers and the
906 // (just-updated) runtime preferences (exit-node selector, accept-routes flag).
907 if let Err(e) = self
908 .env
909 .publish(Arc::new(PeerState {
910 upserts: HashSet::default(),
911 deletions: HashSet::default(),
912 peers: Arc::new(self.peer_db.clone()),
913 }))
914 .await
915 {
916 tracing::error!(error = %e, "re-publishing peer state after a runtime preference change");
917 }
918 }
919}
920
921impl PeerTracker {
922 /// Learn a peer's disco key from a TSMP disco-key advertisement, returning whether the
923 /// advertisement was applied.
924 ///
925 /// Go [`magicsock.Conn.HandleDiscoKeyAdvertisement`], reduced to the state this fork keeps:
926 /// Go stores the learned key on the magicsock endpoint and re-keys its peer map, whereas here
927 /// the peer db's `disco_key` (and its disco index) *is* the live lookup every direct-path
928 /// consumer reads. The key is recorded in the peer's [`EndpointDisco`] TSMP slot — never on top
929 /// of control's — and the peer db then carries whichever of the two is active, so the next
930 /// netmap cannot silently undo it ([`upsert_from_control`](Self::upsert_from_control)).
931 ///
932 /// The three refusals are Go's, in Go's order:
933 ///
934 /// 1. **A zero key is never learned.** Go checks it twice — `tstun` publishes only
935 /// `if !Key.IsZero()`, and `HandleDiscoKeyAdvertisement` rejects it again. The dataplane
936 /// already dropped it here too; this is the second check, kept because the cost of getting
937 /// it wrong is a peer bound to an unusable key.
938 /// 2. **An unknown peer is ignored** (Go: "endpoint not found for node"). An advertisement
939 /// never creates a peer — only control does — so one that arrives before or after the
940 /// peer's netmap entry is a no-op, exactly like a `PeersChangedPatch` for an unknown node.
941 /// 3. **An unchanged key is a no-op**, so a peer re-advertising the key we already hold costs
942 /// no upsert and no republish (Go counts this as
943 /// `magicsock_tsmp_disco_key_advertisement_unchanged` and returns). "Unchanged" is measured
944 /// against the **TSMP-learned** key (Go compares `epDisco.keyFromTSMP()`), NOT against the
945 /// effective one: an advertisement that merely restates what control already told us is new
946 /// information — it is the peer itself confirming the key — so it is recorded as the active
947 /// TSMP key and survives control later dropping or contradicting it.
948 ///
949 /// The tailnet-lock gate is deliberately *not* re-run: unlike a `PeersChangedPatch`, an
950 /// advertisement cannot touch the node key or its TKA signature — only the disco key — so the
951 /// peer-trust decision that admitted this node is unchanged by definition.
952 ///
953 /// [`magicsock.Conn.HandleDiscoKeyAdvertisement`]: https://github.com/tailscale/tailscale/blob/49e148c4a30b4f8098f69468fd27a7021d85ea02/wgengine/magicsock/magicsock.go
954 fn learn_disco_key(&mut self, peer: PeerId, key: DiscoPublicKey) -> bool {
955 if disco_key_is_zero(&key) {
956 tracing::debug!(?peer, "TSMP-advertised disco key is the zero key; ignoring");
957 return false;
958 }
959
960 let Some((_id, existing)) = self.peer_db.get(&peer) else {
961 tracing::debug!(
962 ?peer,
963 "TSMP disco-key advertisement for unknown peer; ignoring"
964 );
965 return false;
966 };
967
968 let node_key = existing.node_key;
969 if self
970 .endpoint_disco
971 .get(&node_key)
972 .and_then(EndpointDisco::key_from_tsmp)
973 == Some(key)
974 {
975 tracing::trace!(?peer, "TSMP-advertised disco key is unchanged");
976 return false;
977 }
978
979 let mut node = existing.clone();
980 let disco = self.endpoint_disco.entry(node_key).or_default();
981 disco.update_from_tsmp(Some(key));
982 node.disco_key = disco.key();
983 self.peer_db.upsert(&node);
984
985 tracing::info!(
986 ?peer,
987 stable_id = ?node.stable_id,
988 %key,
989 "learned peer disco key from a TSMP advertisement"
990 );
991
992 true
993 }
994
995 /// Upsert a control-sourced [`Node`] into the peer db, resolving its disco key against anything
996 /// this peer has told us over TSMP first.
997 ///
998 /// Every node built from control goes through here — `Full`, `Delta { upsert }`, and a
999 /// `PeersChangedPatch` — so the three cannot diverge on which of the two keys wins. This is the
1000 /// disco half of Go [`endpoint.updateFromNode`]: control's key is written through
1001 /// [`EndpointDisco::update_from_control`] **only when it differs from what control last said**
1002 /// (Go's `if discoKey != n.DiscoKey()` guard, which compares `keyFromControl()`, never the
1003 /// effective key). So a netmap that merely restates the key control already sent leaves an
1004 /// active TSMP key alone — which is the entire point of the advertisement, whose motivating case
1005 /// is a peer whose key control has not caught up with. Control genuinely changing its mind still
1006 /// wins, exactly as it does upstream.
1007 ///
1008 /// The node lands in the db carrying the *effective* key ([`EndpointDisco::key`]), so the disco
1009 /// index and every direct-path consumer resolve against the key we would actually send to.
1010 ///
1011 /// [`endpoint.updateFromNode`]: https://github.com/tailscale/tailscale/blob/49e148c4a30b4f8098f69468fd27a7021d85ea02/wgengine/magicsock/endpoint.go
1012 fn upsert_from_control(&mut self, node: &Node) -> PeerId {
1013 let node_key = node.node_key;
1014 let from_control = disco_key_from_control(node.disco_key);
1015
1016 let disco = self.endpoint_disco.entry(node_key).or_default();
1017 if disco.key_from_control() != from_control {
1018 disco.update_from_control(from_control);
1019 }
1020 let effective = disco.key();
1021
1022 // No key material from either source: Go nils the endpoint's `disco` pointer, so a peer
1023 // that has never had a disco key costs us no entry either.
1024 if disco.is_empty() {
1025 self.endpoint_disco.remove(&node_key);
1026 }
1027
1028 if effective == node.disco_key {
1029 return self.peer_db.upsert(node);
1030 }
1031
1032 let mut node = node.clone();
1033 node.disco_key = effective;
1034 self.peer_db.upsert(&node)
1035 }
1036
1037 /// The disco key control last gave us for `node_key` — Go `endpointDisco.keyFromControl()`.
1038 fn control_disco_key(&self, node_key: &NodePublicKey) -> Option<DiscoPublicKey> {
1039 self.endpoint_disco
1040 .get(node_key)
1041 .and_then(EndpointDisco::key_from_control)
1042 }
1043
1044 /// Drop [`EndpointDisco`] state for node keys the peer db no longer holds.
1045 ///
1046 /// Go gets this for free: the two keys live on the magicsock `endpoint`, which the peer map keys
1047 /// by node key and deletes when the peer leaves the netmap — and a peer that rotates its node
1048 /// key gets a brand-new endpoint, so a TSMP-learned key is not carried across a rotation. Here
1049 /// the state is a side table, so every control update prunes it to get the same lifetime.
1050 fn prune_endpoint_disco(&mut self) {
1051 if self.endpoint_disco.is_empty() {
1052 return;
1053 }
1054
1055 let peers = &self.peer_db;
1056 self.endpoint_disco
1057 .retain(|node_key, _| peers.has(node_key).is_some());
1058 }
1059
1060 /// Apply a single [`PeerUpdate`](ts_control::PeerUpdate) to the peer db, enforcing the
1061 /// Tailnet-Lock peer-trust chokepoint ([`tka_admits`](Self::tka_admits)) at every upsert site.
1062 ///
1063 /// This is the **single source of truth** for the peer-trust enforcement loop: the actor's
1064 /// netmap [`handle`](Message::handle) calls it, and so do the TKA enforcement tests, so the two
1065 /// real upsert sites (`Full` and `Delta { upsert }`) cannot diverge from what is tested.
1066 ///
1067 /// Returns `(upserts, deletions)` — the [`PeerId`]s touched — for downstream bookkeeping.
1068 fn apply_peer_update(
1069 &mut self,
1070 peer_update: &ts_control::PeerUpdate,
1071 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
1072 let mut upserts = HashSet::default();
1073 let mut deletions = HashSet::default();
1074
1075 match peer_update {
1076 ts_control::PeerUpdate::Full(new_nodes) => {
1077 tracing::trace!("full peer update");
1078
1079 // Borrow the authority ONCE for the whole batch and verify each peer EXACTLY once
1080 // (Go runs `tkaFilterNetmapLocked` once over the assembled netmap; an earlier draft
1081 // verified every peer twice — once for `retained_ids`, once in the upsert loop —
1082 // doubling the ed25519 cost on the hot resync path). `tka_keep_verdicts` is that one
1083 // pass — per-peer signature verdict AND the cross-peer rotation filter — and is
1084 // shared verbatim with `tka_reevaluate_peer_db`, so the netmap path and the
1085 // authority-install path cannot drift apart on what "admitted" means.
1086 //
1087 // The result is a per-NODE keep vector (not a stable_id set), which drives both the
1088 // `retain` (evict revoked peers, keyed by stable_id) and the upsert loop. Judging
1089 // each node by its own verdict means a node whose signature fails is never admitted
1090 // on the strength of a different node that happens to share its stable_id.
1091 //
1092 // Revocation evicts: a peer re-included with a now-invalid/missing signature under an
1093 // active authority fails its verdict, so it is excluded from `retained_ids` and
1094 // `retain` drops the stale (previously-admitted) entry. With no authority the snapshot
1095 // is `None`, so every node passes — byte-for-byte the pre-TKA behavior (no regression).
1096 let authority = self.tka_authority_snapshot();
1097 let node_refs = new_nodes.iter().collect::<Vec<&Node>>();
1098 let keep = Self::tka_keep_verdicts(authority.as_deref(), &node_refs);
1099
1100 // `retained_ids` is the set of stable_ids that survive (drives `retain` to evict the
1101 // rest). It must agree with what the upsert loop below will leave in the db. Control
1102 // should never send two distinct nodes with the same `stable_id` in one `Full`, but if
1103 // it does, `peer_db.upsert` is last-writer-wins on `stable_id`, so the db ends holding
1104 // the LAST kept node for that id. Build `retained_ids` from kept nodes only — a
1105 // stable_id is retained iff at least one of its (possibly duplicate) nodes is kept, so
1106 // the upsert loop's last-kept node lands and `retain` never evicts a just-upserted id.
1107 let retained_ids = new_nodes
1108 .iter()
1109 .zip(keep.iter().copied())
1110 .filter(|(_, k)| *k)
1111 .map(|(node, _)| &node.stable_id)
1112 .collect::<HashSet<_>>();
1113
1114 // Isolation diagnostic: an ACTIVE lock that authorized none of the offered peers
1115 // leaves this node with no peers — surface it loudly so a self-lockout (vs an attack)
1116 // is diagnosable. `authority.is_some()` means a real keyed lock (the empty-keyset
1117 // brick-guard admits-all, so it never reaches here with zero retained).
1118 if authority.is_some() && !new_nodes.is_empty() && retained_ids.is_empty() {
1119 tracing::error!(
1120 offered = new_nodes.len(),
1121 "TKA: active lock authorized ZERO of the offered peers; node is isolated \
1122 (verify the lock state, or disable tailnet lock to recover)"
1123 );
1124 }
1125
1126 self.peer_db.retain(|id, peer| {
1127 let retain = retained_ids.contains(&peer.stable_id);
1128
1129 if !retain {
1130 deletions.insert(id);
1131 }
1132
1133 retain
1134 });
1135
1136 for (node, k) in new_nodes.iter().zip(keep.iter().copied()) {
1137 if !k {
1138 continue; // fail-CLOSED: rejected by tailnet lock or rotation-obsolete (above)
1139 }
1140 let peer_id = self.upsert_from_control(node);
1141 upserts.insert(peer_id);
1142 }
1143 }
1144
1145 ts_control::PeerUpdate::Delta { remove, upsert } => {
1146 tracing::trace!("delta peer update");
1147
1148 for peer in upsert {
1149 if !self.tka_admits(peer) {
1150 // fail-CLOSED: do not upsert a peer rejected by tailnet lock. If the peer is
1151 // ALREADY in the db (a delta re-upserting an existing peer whose signature is
1152 // now invalid — e.g. revoked between syncs), evict the stale entry rather than
1153 // leaving an unverified peer admitted; Go re-filters the whole netmap each map
1154 // response, so a now-unsigned peer would not survive there either.
1155 if let Some((id, _)) = self.peer_db.remove(&peer.stable_id) {
1156 tracing::warn!(
1157 stable_id = ?peer.stable_id,
1158 "TKA: delta re-upsert rejected; evicting now-unauthorized peer"
1159 );
1160 deletions.insert(id);
1161 }
1162 continue;
1163 }
1164 let id = self.upsert_from_control(peer);
1165
1166 upserts.insert(id);
1167 }
1168
1169 for peer in remove {
1170 let Some((id, _node)) = self.peer_db.remove(peer) else {
1171 // A benign, expected race: the peer may already be gone (dropped in a prior
1172 // `Full`, or fail-closed by TKA — whose now-"unknown" ids commonly reappear in
1173 // a trailing `peers_removed`). Go treats an unknown removal as a no-op; log at
1174 // debug, not error, to avoid false-alarm noise on a healthy node (matches the
1175 // unknown-node handling in `apply_peer_patches`).
1176 tracing::debug!(
1177 control_node_id = peer,
1178 "removed peer was unknown; ignoring"
1179 );
1180 continue;
1181 };
1182
1183 deletions.insert(id);
1184 }
1185 }
1186 }
1187
1188 self.prune_endpoint_disco();
1189
1190 (upserts, deletions)
1191 }
1192
1193 /// Re-run the Tailnet-Lock filter over the peers **already in the peer db**, evicting the ones
1194 /// the current authority does not admit. Returns the evicted [`PeerId`]s (empty when nothing
1195 /// changed, which is the overwhelmingly common case).
1196 ///
1197 /// # Why this exists (a Go-ordering gap, not an extra feature)
1198 /// Go filters the very netmap that announced the lock: `SetControlClientStatus`
1199 /// (`ipn/ipnlocal/local.go`, v1.100.0) calls `tkaSyncIfNeeded` and then, a few lines later,
1200 /// `tkaFilterNetmapLocked(st.NetMap)` — synchronously, on the same `st.NetMap`, in one pass. So
1201 /// the peers announced alongside `TKAEnabled` are checked by the authority that sync just built.
1202 ///
1203 /// Here the sync is a spawned task (`control_runner`'s `maybe_sync_tka`), so the ordering is
1204 /// inverted: the netmap that carried the `TkaStatus` reaches the peer db *before* the authority
1205 /// exists, and is admitted with enforcement inactive. Without this pass those peers stay
1206 /// admitted — unauthorized ones included — until control happens to send another `Full`, which on
1207 /// a steady map poll may be never. That is the whole initial peer set escaping a lock the node
1208 /// really did sync, so this runs the moment the authority is installed ([`TkaAuthorityChanged`])
1209 /// and brings the db back in line.
1210 ///
1211 /// No authority (nothing synced yet, or the lock was disabled) ⇒ no eviction: enforcement is
1212 /// inactive and every peer is admitted, exactly Go's `b.tka == nil` early return. A peer dropped
1213 /// while the lock was active is **not** resurrected by a later disable — the db no longer holds
1214 /// it and this fork keeps no shadow copy of filtered nodes (Go's `b.tka.filtered`); it returns on
1215 /// the next netmap that re-includes it. That is the safe direction: more restrictive, and
1216 /// connectivity-only.
1217 fn tka_reevaluate_peer_db(&mut self) -> HashSet<PeerId> {
1218 let Some(authority) = self.tka_authority_snapshot() else {
1219 return HashSet::default();
1220 };
1221
1222 // Verdicts first, under an immutable borrow of the db; the eviction below needs `&mut`.
1223 let evicted: HashSet<PeerId> = {
1224 let entries = self
1225 .peer_db
1226 .peers()
1227 .iter()
1228 .map(|(id, node)| (*id, node))
1229 .collect::<Vec<(PeerId, &Node)>>();
1230 let nodes = entries
1231 .iter()
1232 .map(|(_, node)| *node)
1233 .collect::<Vec<&Node>>();
1234 let keep = Self::tka_keep_verdicts(Some(&authority), &nodes);
1235 entries
1236 .iter()
1237 .zip(keep)
1238 .filter_map(|((id, _), keep)| (!keep).then_some(*id))
1239 .collect()
1240 };
1241
1242 if evicted.is_empty() {
1243 return evicted;
1244 }
1245
1246 tracing::warn!(
1247 n_evicted = evicted.len(),
1248 peer_count = self.peer_db.peers().len(),
1249 "TKA: re-filtered the peer db against the newly installed lock authority; evicted \
1250 already-admitted peers"
1251 );
1252 self.peer_db.retain(|id, _| !evicted.contains(&id));
1253 self.prune_endpoint_disco();
1254 evicted
1255 }
1256
1257 /// Apply field-level peer patches (`MapResponse.PeersChangedPatch`), returning the upserted /
1258 /// deleted [`PeerId`]s.
1259 ///
1260 /// This is a SEPARATE channel from [`apply_peer_update`](Self::apply_peer_update): Go's
1261 /// `controlclient` applies the whole-node `Peers*` set first and then `PeersChangedPatch`, so a
1262 /// response that carries both has the peer set applied first (by the caller) and these patches
1263 /// applied second, on top of the freshly-synced nodes. A patch only mutates a peer already in the
1264 /// netmap; an unknown node id is ignored (the wire contract — a patch never creates a node).
1265 fn apply_peer_patches(
1266 &mut self,
1267 patches: &[ts_control::PeerChange],
1268 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
1269 let mut upserts = HashSet::default();
1270 let mut deletions = HashSet::default();
1271
1272 tracing::trace!(n = patches.len(), "peer patch update");
1273
1274 for patch in patches {
1275 // Clone the current node, apply the present fields, and re-upsert through the same path
1276 // as a delta so indexes/routes stay consistent.
1277 let Some((_id, existing)) = self.peer_db.get(&patch.id) else {
1278 tracing::debug!(
1279 control_node_id = patch.id,
1280 "peer patch for unknown node; ignoring"
1281 );
1282 continue;
1283 };
1284
1285 let mut node = existing.clone();
1286 if let Some(endpoints) = &patch.underlay_addresses {
1287 node.underlay_addresses = endpoints.clone();
1288 }
1289 if let Some(derp) = patch.derp_region {
1290 node.derp_region = Some(derp);
1291 }
1292 if let Some(cap) = patch.cap {
1293 node.cap = cap;
1294 }
1295 if let Some(cap_map) = &patch.cap_map {
1296 node.cap_map = cap_map.clone();
1297 }
1298 // The db entry carries the EFFECTIVE disco key, which may have been learned over TSMP,
1299 // so restate what CONTROL last said before folding the patch in. Otherwise a patch that
1300 // says nothing about the disco key would hand a TSMP-learned key back as if control had
1301 // sent it, and `upsert_from_control` would read that as control having caught up —
1302 // deactivating the TSMP key on a patch that never mentioned it.
1303 node.disco_key = self.control_disco_key(&node.node_key);
1304 if let Some(disco_key) = patch.disco_key {
1305 node.disco_key = Some(disco_key);
1306 }
1307 if let Some(expiry) = patch.node_key_expiry {
1308 node.node_key_expiry = Some(expiry);
1309 }
1310 // Online/last-seen liveness deltas (`PeerChange.Online`/`LastSeen`) — the dominant
1311 // channel by which peer online transitions arrive mid-session. A patch only ever *sets*
1312 // a value (never patches back to unknown), so apply when present.
1313 if let Some(online) = patch.online {
1314 node.online = Some(online);
1315 }
1316 if let Some(last_seen) = patch.last_seen {
1317 node.last_seen = Some(last_seen);
1318 }
1319 // Key rotation: a patch may swap the node key (and its TKA signature). Apply both
1320 // together so the trust gate below verifies the new signature against the new key, never
1321 // a mismatched pair.
1322 if let Some(node_key) = patch.node_key {
1323 node.node_key = node_key;
1324 }
1325 if let Some(sig) = &patch.key_signature {
1326 node.key_signature = sig.clone();
1327 }
1328
1329 // Re-run the tailnet-lock gate on the patched node: a patch that rotates the key must
1330 // satisfy the active authority, exactly like a `Delta` upsert, or it would be a
1331 // trust-enforcement bypass. fail-CLOSED — if the patched node is no longer admitted,
1332 // evict it rather than keep the stale (now-unverified) entry.
1333 if !self.tka_admits(&node) {
1334 if let Some((id, _)) = self.peer_db.remove(&patch.id) {
1335 tracing::warn!(
1336 control_node_id = patch.id,
1337 "peer patch rejected by tailnet lock; evicting peer"
1338 );
1339 deletions.insert(id);
1340 }
1341 continue;
1342 }
1343
1344 let id = self.upsert_from_control(&node);
1345 upserts.insert(id);
1346 }
1347
1348 self.prune_endpoint_disco();
1349
1350 (upserts, deletions)
1351 }
1352
1353 /// Apply the standalone online/last-seen delta maps (`MapResponse.OnlineChange` /
1354 /// `PeerSeenChange`, channels C/D) onto the retained netmap. Returns `true` if any node was
1355 /// actually mutated (so the caller knows whether to re-publish).
1356 ///
1357 /// Mirrors Go `controlclient/map.go:updatePeersStateFromResponse` (the two channels are
1358 /// semantically DISTINCT and must not be conflated):
1359 /// - `OnlineChange` (channel C) is the sole driver of a peer's `online` flag (`mut.Online = v`).
1360 /// - `PeerSeenChange` (channel D) is the sole driver of `last_seen`: `true ⇒ LastSeen = now`,
1361 /// `false ⇒ LastSeen = nil` (cleared). It NEVER touches `online` — "not seen recently" is not
1362 /// the same as "offline", which only `OnlineChange` asserts.
1363 ///
1364 /// Each entry is keyed by control node id and applies to a peer already in the netmap; an unknown
1365 /// node id is ignored (these maps never create a node). `now` is the wall-clock timestamp for a
1366 /// `PeerSeenChange: true` (Go uses `clock.Now()`); the caller passes it so this stays a pure
1367 /// function of its inputs. Returns `true` if any node was actually mutated.
1368 fn apply_liveness_changes(
1369 &mut self,
1370 online_change: &std::collections::BTreeMap<ts_control::NodeId, bool>,
1371 peer_seen_change: &std::collections::BTreeMap<ts_control::NodeId, bool>,
1372 now: chrono::DateTime<chrono::Utc>,
1373 ) -> bool {
1374 let mut changed = false;
1375
1376 // Channel C — direct online flips (the only writer of `online`).
1377 for (&node_id, &online) in online_change {
1378 if let Some((_pid, existing)) = self.peer_db.get(&node_id)
1379 && existing.online != Some(online)
1380 {
1381 let mut node = existing.clone();
1382 node.online = Some(online);
1383 self.peer_db.upsert(&node);
1384 changed = true;
1385 }
1386 }
1387
1388 // Channel D — peer-seen flips (the only writer of `last_seen`; never touches `online`).
1389 // `true` ⇒ last-seen is now; `false` ⇒ last-seen cleared (Go map.go:820-830).
1390 for (&node_id, &seen) in peer_seen_change {
1391 let new_last_seen = if seen { Some(now) } else { None };
1392 if let Some((_pid, existing)) = self.peer_db.get(&node_id)
1393 && existing.last_seen != new_last_seen
1394 {
1395 let mut node = existing.clone();
1396 node.last_seen = new_last_seen;
1397 self.peer_db.upsert(&node);
1398 changed = true;
1399 }
1400 }
1401
1402 changed
1403 }
1404
1405 /// Test-only constructor: build a [`PeerTracker`] with a chosen initial TKA authority without
1406 /// going through the actor `on_start` path. Returns the tracker plus the **`watch::Sender`** for
1407 /// its enforcement-authority cell, so a test can drive the exact enable/disable transitions the
1408 /// control runner drives at runtime (`tx.send_replace(Some(..))` ⇒ enforce, `tx.send_replace(None)`
1409 /// ⇒ clear). The initial `Some` exercises the fail-closed chokepoint
1410 /// ([`tka_admits`](Self::tka_admits)); `None` is the no-lock admit-all path. The returned sender
1411 /// must be kept alive for the tracker to read updated values.
1412 #[cfg(test)]
1413 fn for_test(
1414 env: Env,
1415 tka_authority: Option<ts_tka::Authority>,
1416 ) -> (Self, watch::Sender<Option<Arc<ts_tka::Authority>>>) {
1417 let (peer_watch, _) = watch::channel(Vec::new());
1418 let (tka_tx, tka_rx) = watch::channel(tka_authority.map(Arc::new));
1419 let tracker = Self {
1420 peer_db: PeerDb::default(),
1421 seen_state_update: false,
1422 pending_requests: Vec::new(),
1423 peer_watch,
1424 user_profiles: HashMap::new(),
1425 endpoint_disco: HashMap::new(),
1426 tka_authority: tka_rx,
1427 env,
1428 };
1429 (tracker, tka_tx)
1430 }
1431
1432 fn service_pending_requests(&mut self) {
1433 if self.seen_state_update {
1434 return;
1435 }
1436
1437 self.seen_state_update = true;
1438
1439 if !self.pending_requests.is_empty() {
1440 tracing::debug!(
1441 n_pending = self.pending_requests.len(),
1442 "state update received, servicing pending requests"
1443 );
1444 }
1445
1446 for req in core::mem::take(&mut self.pending_requests) {
1447 match req {
1448 Pending::PeerByName(PeerByName { name }, reply) => {
1449 reply.send(self.peer_by_name_opt(&name).cloned());
1450 }
1451 Pending::TailnetIp(PeerByTailnetIp { ip }, reply) => {
1452 reply.send(self.peer_by_tailnet_ip_opt(ip).cloned());
1453 }
1454 Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, reply) => {
1455 reply.send(
1456 self.peer_db
1457 .get_route(ip.into())
1458 .map(|(_id, node)| node.clone())
1459 .collect(),
1460 );
1461 }
1462 Pending::Status(reply) => {
1463 reply.send(self.status_peers_with_ids());
1464 }
1465 Pending::WhoIs(Whois { addr }, reply) => {
1466 reply.send(self.whois_opt(addr));
1467 }
1468 }
1469 }
1470 }
1471}
1472
1473#[cfg(test)]
1474mod tka_tests {
1475 //! Tailnet-Lock (TKA) enforcement tests for the peer-trust chokepoint.
1476 //!
1477 //! These exercise [`PeerTracker::tka_admits`] and the `tka_admits ⇒ upsert` loop the netmap
1478 //! handler runs. The test [`ts_tka::Authority`] is built with [`ts_tka::Authority::from_state`]
1479 //! over a known Ed25519 trusted key, and the signed node-key signature CBOR is produced through
1480 //! `ts_tka`'s public `cbor` encoder + `aum_hash` (the exact same canonical bytes `ts_tka`'s own
1481 //! `direct_signature_verifies_end_to_end` test signs, with no new crypto vectors invented and no
1482 //! private `ts_tka` API used).
1483
1484 use ed25519_dalek::{Signer, SigningKey};
1485 use ts_control::{Node, StableNodeId, TailnetAddress};
1486 use ts_tka::{
1487 AumHash, Authority, Key, KeyKind, State,
1488 cbor::{self, Value},
1489 };
1490
1491 use super::*;
1492
1493 /// `SigKind::Direct` wire value (Go `SigKind`; `ts_tka::SigKind::Direct = 1`).
1494 const SIG_KIND_DIRECT: u64 = 1;
1495
1496 /// The 32-byte node key used across the signed-peer fixtures.
1497 const NODE_KEY_BYTES: [u8; 32] = [7u8; 32];
1498
1499 /// Build a real [`Env`] for the tracker. Only the bus/keys/shutdown plumbing matters here; the
1500 /// TKA gate reads neither, so the forwarding preferences are all benign defaults.
1501 pub(super) fn test_env() -> Env {
1502 let (_shutdown_tx, shutdown_rx) = watch::channel(false);
1503 Env::new(
1504 ts_keys::NodeState::generate(),
1505 shutdown_rx,
1506 crate::env::ForwarderConfig {
1507 accept_routes: false,
1508 accept_dns: true,
1509 exit_node: None,
1510 forward_routes: Vec::new(),
1511 forward_tcp_ports: Vec::new(),
1512 forward_udp_ports: Vec::new(),
1513 forward_all_ports: false,
1514 forward_exit_egress: false,
1515 block_incoming: false,
1516 exit_proxy: None,
1517 peerapi_port: None,
1518 taildrop_dir: None,
1519 enable_ipv6: false,
1520 wireguard_listen_port: None,
1521 network_monitor: false,
1522 persistent_keepalive_interval: None,
1523 ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1524 },
1525 )
1526 }
1527
1528 /// A minimal peer [`Node`] carrying `node_key` and the given `key_signature`.
1529 pub(super) fn peer_node(stable_id: &str, node_key: [u8; 32], key_signature: Vec<u8>) -> Node {
1530 Node {
1531 id: 1,
1532 stable_id: StableNodeId(stable_id.to_string()),
1533 hostname: stable_id.to_string(),
1534 user_id: 0,
1535 tailnet: Some("ts.net".to_string()),
1536 tags: Vec::new(),
1537 addresses: vec![
1538 "100.64.0.1/32".parse().unwrap(),
1539 "fd7a:115c:a1e0::1/128".parse().unwrap(),
1540 ],
1541 tailnet_address: TailnetAddress {
1542 ipv4: "100.64.0.1/32".parse().unwrap(),
1543 ipv6: "fd7a:115c:a1e0::1/128".parse().unwrap(),
1544 },
1545 node_key: node_key.into(),
1546 node_key_expiry: None,
1547 online: None,
1548 last_seen: None,
1549 key_signature,
1550 machine_key: None,
1551 disco_key: None,
1552 accepted_routes: Vec::new(),
1553 underlay_addresses: Vec::new(),
1554 derp_region: None,
1555 cap: Default::default(),
1556 cap_map: Default::default(),
1557 peerapi_port: None,
1558 peerapi_dns_proxy: false,
1559 is_wireguard_only: false,
1560 exit_node_dns_resolvers: Vec::new(),
1561 peer_relay: false,
1562 ssh_host_keys: Vec::new(),
1563 service_vips: Default::default(),
1564 }
1565 }
1566
1567 /// Encode a `Direct` [`ts_tka::NodeKeySignature`] CBOR exactly as `ts_tka`'s private `to_cbor`
1568 /// does (int-map keys: 1=kind, 2=pubkey, 3=key_id, 4=signature; empty byte fields omitted),
1569 /// using only the crate's *public* `cbor` encoder. `signature` of `None` produces the
1570 /// signing-digest preimage (the `SigHash` form).
1571 fn direct_sig_cbor(node_key: &[u8], key_id: &[u8], signature: Option<&[u8]>) -> Vec<u8> {
1572 let mut pairs = alloc_pairs(node_key, key_id);
1573 if let Some(sig) = signature {
1574 pairs.push((4, Some(Value::Bytes(sig.to_vec()))));
1575 }
1576 cbor::int_map(pairs).to_vec()
1577 }
1578
1579 fn alloc_pairs(node_key: &[u8], key_id: &[u8]) -> Vec<(u64, Option<Value>)> {
1580 vec![
1581 (1, Some(Value::Uint(SIG_KIND_DIRECT))),
1582 (2, Some(Value::Bytes(node_key.to_vec()))),
1583 (3, Some(Value::Bytes(key_id.to_vec()))),
1584 ]
1585 }
1586
1587 /// Build a TKA [`Authority`] that trusts `signing.verifying_key()`, plus a valid `Direct`
1588 /// node-key signature CBOR authorizing [`NODE_KEY_BYTES`] under it.
1589 fn authority_and_valid_sig() -> (Authority, Vec<u8>) {
1590 // A fixed, known Ed25519 trusted key (mirrors ts_tka's own end-to-end test seed).
1591 let signing = SigningKey::from_bytes(&[42u8; 32]);
1592 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
1593
1594 let authority = Authority::from_state(
1595 AumHash([0; 32]),
1596 State {
1597 keys: vec![Key {
1598 kind: KeyKind::Ed25519,
1599 votes: 1,
1600 public: trusted_pub.clone(),
1601 }],
1602 },
1603 );
1604
1605 // SigHash preimage = canonical CBOR with the signature field omitted; sign its blake2s hash.
1606 let preimage = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, None);
1607 let sig_hash = ts_tka::aum_hash(&preimage).0;
1608 let signature = signing.sign(&sig_hash).to_bytes().to_vec();
1609
1610 let signed_cbor = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, Some(&signature));
1611 // Sanity: the authority accepts the signature we just built (same path the gate uses).
1612 assert!(
1613 authority
1614 .node_key_authorized(&NODE_KEY_BYTES, &signed_cbor)
1615 .is_ok()
1616 );
1617
1618 (authority, signed_cbor)
1619 }
1620
1621 #[tokio::test]
1622 async fn tka_inactive_upserts_all_peers() {
1623 // No authority ⇒ enforcement inactive ⇒ both a signed and an unsigned peer are admitted.
1624 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
1625
1626 let signed = peer_node("signed", [1u8; 32], vec![0xde, 0xad, 0xbe, 0xef]);
1627 let unsigned = peer_node("unsigned", [2u8; 32], vec![]);
1628
1629 assert!(tracker.tka_admits(&signed));
1630 assert!(tracker.tka_admits(&unsigned));
1631
1632 tracker.peer_db.upsert(&signed);
1633 tracker.peer_db.upsert(&unsigned);
1634 assert_eq!(tracker.peer_db.peers().len(), 2);
1635 }
1636
1637 #[tokio::test]
1638 async fn tka_active_rejects_unsigned_peer() {
1639 // Authority present + peer presents no signature ⇒ rejected (fail-closed), not in peer_db.
1640 let (authority, _sig) = authority_and_valid_sig();
1641 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1642
1643 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
1644 assert!(!tracker.tka_admits(&unsigned));
1645
1646 // Mirror the handler's `if !tka_admits { continue }` loop.
1647 if tracker.tka_admits(&unsigned) {
1648 tracker.peer_db.upsert(&unsigned);
1649 }
1650 assert_eq!(tracker.peer_db.peers().len(), 0);
1651 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1652 }
1653
1654 #[tokio::test]
1655 async fn tka_active_rejects_bad_signature() {
1656 // Authority present + a signature that fails to verify ⇒ rejected, not in peer_db.
1657 let (authority, mut sig) = authority_and_valid_sig();
1658 // Tamper the last byte (the trailing signature byte) so verification fails.
1659 let last = sig.len() - 1;
1660 sig[last] ^= 0xff;
1661
1662 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1663 let bad = peer_node("bad", NODE_KEY_BYTES, sig);
1664 assert!(!tracker.tka_admits(&bad));
1665
1666 if tracker.tka_admits(&bad) {
1667 tracker.peer_db.upsert(&bad);
1668 }
1669 assert_eq!(tracker.peer_db.peers().len(), 0);
1670 }
1671
1672 #[tokio::test]
1673 async fn tka_active_admits_authorized_peer() {
1674 // Authority present + correctly-signed node key ⇒ admitted and upserted.
1675 let (authority, sig) = authority_and_valid_sig();
1676 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1677
1678 let good = peer_node("good", NODE_KEY_BYTES, sig);
1679 assert!(tracker.tka_admits(&good));
1680
1681 if tracker.tka_admits(&good) {
1682 tracker.peer_db.upsert(&good);
1683 }
1684 assert_eq!(tracker.peer_db.peers().len(), 1);
1685 assert!(tracker.peer_db.get(&good.node_key).is_some());
1686 }
1687
1688 // ---------------------------------------------------------------------------------------------
1689 // Tests that drive REAL `PeerUpdate`s through the shared handler body
1690 // ([`PeerTracker::apply_peer_update`], the single source of truth the actor's netmap `handle`
1691 // also calls), so the two real upsert sites (`Full` and `Delta { upsert }`) are exercised via
1692 // the actual enforcement path — not by hand-mirroring `if !tka_admits { continue }`.
1693 // ---------------------------------------------------------------------------------------------
1694
1695 #[tokio::test]
1696 async fn tka_active_delta_upsert_rejects_unauthorized() {
1697 // Drive a real `Delta { upsert }` whose peer carries no signature. The Delta upsert site
1698 // must reject it under an active authority ⇒ not present in peer_db after the handler runs.
1699 let (authority, _sig) = authority_and_valid_sig();
1700 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1701
1702 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
1703 let update = ts_control::PeerUpdate::Delta {
1704 upsert: vec![unsigned.clone()],
1705 remove: Vec::new(),
1706 };
1707
1708 tracker.apply_peer_update(&update);
1709
1710 assert_eq!(tracker.peer_db.peers().len(), 0);
1711 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1712 }
1713
1714 #[tokio::test]
1715 async fn tka_active_delta_upsert_admits_authorized() {
1716 // Drive a real `Delta { upsert }` with a correctly-signed peer ⇒ present in peer_db.
1717 let (authority, sig) = authority_and_valid_sig();
1718 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1719
1720 let good = peer_node("good", NODE_KEY_BYTES, sig);
1721 let update = ts_control::PeerUpdate::Delta {
1722 upsert: vec![good.clone()],
1723 remove: Vec::new(),
1724 };
1725
1726 tracker.apply_peer_update(&update);
1727
1728 assert_eq!(tracker.peer_db.peers().len(), 1);
1729 assert!(tracker.peer_db.get(&good.node_key).is_some());
1730 }
1731
1732 #[tokio::test]
1733 async fn tka_active_full_admits_only_authorized_in_mixed_batch() {
1734 // Drive a real `Full` carrying a MIX of authorized + unauthorized peers. Only the
1735 // correctly-signed peer survives the Full upsert site; the unsigned and bad-sig peers are
1736 // dropped fail-closed.
1737 let (authority, sig) = authority_and_valid_sig();
1738 // A bad-sig variant of the same authorized signature (tamper the trailing byte).
1739 let mut bad_sig = sig.clone();
1740 let last = bad_sig.len() - 1;
1741 bad_sig[last] ^= 0xff;
1742
1743 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
1744
1745 // Only the authorized peer carries NODE_KEY_BYTES (the key the authority signed); the
1746 // rejected peers use distinct node keys so the survivor is unambiguous.
1747 let good = peer_node("good", NODE_KEY_BYTES, sig);
1748 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
1749 let bad = peer_node("bad", [9u8; 32], bad_sig);
1750
1751 let update =
1752 ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]);
1753
1754 tracker.apply_peer_update(&update);
1755
1756 assert_eq!(tracker.peer_db.peers().len(), 1);
1757 assert!(tracker.peer_db.get(&good.node_key).is_some());
1758 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1759 assert!(tracker.peer_db.get(&bad.node_key).is_none());
1760 }
1761
1762 /// End-to-end through the REAL enforcement-authority transport (the `watch` cell the control
1763 /// runner writes), not a direct field poke: writing `Some(authority)` flips enforcement on so a
1764 /// mixed batch drops the unsigned/bad peers, and a subsequent `None` (lock disabled) clears
1765 /// enforcement so a peer DROPPED while enforced is re-admitted. Exercises the exact `borrow`-based
1766 /// read path `tka_admits` uses — a broken receiver wiring would pass every for_test-field test but
1767 /// fail here.
1768 #[tokio::test]
1769 async fn tka_authority_watch_enables_then_clears_enforcement() {
1770 let (authority, sig) = authority_and_valid_sig();
1771 let mut bad_sig = sig.clone();
1772 let last = bad_sig.len() - 1;
1773 bad_sig[last] ^= 0xff;
1774
1775 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
1776
1777 // 1) No authority yet ⇒ admit-all (Go b.tka == nil).
1778 let good = peer_node("good", NODE_KEY_BYTES, sig.clone());
1779 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
1780 let bad = peer_node("bad", [9u8; 32], bad_sig);
1781 let batch = ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]);
1782 tracker.apply_peer_update(&batch);
1783 assert_eq!(tracker.peer_db.peers().len(), 3, "no lock ⇒ admit all");
1784
1785 // 2) Publish the verified authority over the watch cell (exactly what the control runner does
1786 // on a successful sync) ⇒ enforcement ON. A re-applied Full now drops unsigned + bad.
1787 tka_tx.send_replace(Some(Arc::new(authority)));
1788 tracker.apply_peer_update(&batch);
1789 assert_eq!(
1790 tracker.peer_db.peers().len(),
1791 1,
1792 "lock active ⇒ only the signed peer survives"
1793 );
1794 assert!(tracker.peer_db.get(&good.node_key).is_some());
1795 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1796 assert!(tracker.peer_db.get(&bad.node_key).is_none());
1797
1798 // 3) Lock disabled (None) ⇒ enforcement cleared ⇒ a peer that was DROPPED while enforced is
1799 // re-admitted by a fresh netmap. Assert the specific previously-dropped key returns (not
1800 // merely a count), so this proves the drop→clear→re-admit transition, not "admit-all-fresh".
1801 tka_tx.send_replace(None);
1802 tracker.apply_peer_update(&batch);
1803 assert_eq!(
1804 tracker.peer_db.peers().len(),
1805 3,
1806 "lock disabled ⇒ admit all again"
1807 );
1808 assert!(
1809 tracker.peer_db.get(&unsigned.node_key).is_some(),
1810 "the peer dropped under enforcement must come back once the lock is cleared"
1811 );
1812 assert!(tracker.peer_db.get(&bad.node_key).is_some());
1813 }
1814
1815 /// The ordering gap this closes. A peer admitted BEFORE the lock synced must be re-checked the
1816 /// moment the authority is installed — not left in the db until control happens to send another
1817 /// `Full`. Go never has this problem: `SetControlClientStatus` runs `tkaSyncIfNeeded` and then
1818 /// `tkaFilterNetmapLocked(st.NetMap)` on the SAME netmap in one pass, so the netmap that
1819 /// announced the lock is itself filtered. Here the sync is a spawned task, so the netmap lands
1820 /// first and `tka_reevaluate_peer_db` is what restores Go's ordering.
1821 ///
1822 /// Note this test applies NO second netmap: the eviction must come from the authority install
1823 /// alone, which is exactly what was missing before.
1824 #[tokio::test]
1825 async fn tka_authority_install_reevaluates_already_admitted_peers() {
1826 let (authority, sig) = authority_and_valid_sig();
1827 let mut bad_sig = sig.clone();
1828 let last = bad_sig.len() - 1;
1829 bad_sig[last] ^= 0xff;
1830
1831 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
1832
1833 // 1) A netmap arrives while nothing is synced ⇒ enforcement inactive ⇒ all three admitted.
1834 let good = peer_node("good", NODE_KEY_BYTES, sig);
1835 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
1836 let bad = peer_node("bad", [9u8; 32], bad_sig);
1837 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
1838 good.clone(),
1839 unsigned.clone(),
1840 bad.clone(),
1841 ]));
1842 assert_eq!(tracker.peer_db.peers().len(), 3, "no lock yet ⇒ admit all");
1843 let unsigned_id = tracker
1844 .peer_db
1845 .get(&unsigned.node_key)
1846 .expect("unsigned peer admitted while no lock is synced")
1847 .0;
1848 let bad_id = tracker
1849 .peer_db
1850 .get(&bad.node_key)
1851 .expect("bad-sig peer admitted while no lock is synced")
1852 .0;
1853
1854 // 2) The sync completes and the control runner installs the verified authority.
1855 tka_tx.send_replace(Some(Arc::new(authority)));
1856 let evicted = tracker.tka_reevaluate_peer_db();
1857
1858 assert_eq!(
1859 evicted,
1860 HashSet::from_iter([unsigned_id, bad_id]),
1861 "the unsigned and bad-signature peers are the ones reported evicted"
1862 );
1863 assert_eq!(tracker.peer_db.peers().len(), 1);
1864 assert!(
1865 tracker.peer_db.get(&good.node_key).is_some(),
1866 "the authorized peer stays admitted"
1867 );
1868 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
1869 assert!(tracker.peer_db.get(&bad.node_key).is_none());
1870
1871 // 3) Idempotent: a second pass over the now-clean db evicts nobody.
1872 assert!(tracker.tka_reevaluate_peer_db().is_empty());
1873 }
1874
1875 /// With no authority the re-evaluation evicts nobody — enforcement is inactive and every peer is
1876 /// admitted, exactly Go's `b.tka == nil` early return. Covers both "never synced" and "the lock
1877 /// was disabled after enforcing", the two ways the cell holds `None`.
1878 #[tokio::test]
1879 async fn tka_reevaluate_without_authority_evicts_nothing() {
1880 let (authority, sig) = authority_and_valid_sig();
1881 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
1882
1883 let good = peer_node("good", NODE_KEY_BYTES, sig);
1884 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
1885 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
1886 good.clone(),
1887 unsigned.clone(),
1888 ]));
1889
1890 // Never synced.
1891 assert!(tracker.tka_reevaluate_peer_db().is_empty());
1892 assert_eq!(tracker.peer_db.peers().len(), 2);
1893
1894 // Enforced, then disabled: the disable must not evict the peer the lock had authorized, and
1895 // must not start dropping the unsigned one either.
1896 tka_tx.send_replace(Some(Arc::new(authority)));
1897 assert_eq!(tracker.tka_reevaluate_peer_db().len(), 1);
1898 tka_tx.send_replace(None);
1899 assert!(tracker.tka_reevaluate_peer_db().is_empty());
1900 assert!(tracker.peer_db.get(&good.node_key).is_some());
1901 }
1902
1903 /// The re-evaluation runs the WHOLE Go `tkaFilterNetmapLocked` pass, not just the per-peer
1904 /// signature check: a peer presenting a node key that a newer rotation superseded is evicted too,
1905 /// even though its own `Direct` signature still verifies against the authority. Both peers are
1906 /// already in the db when the authority lands, so the cross-peer rotation filter has to run over
1907 /// the db contents — which is why `tka_keep_verdicts` is shared with the `Full` path rather than
1908 /// re-derived here.
1909 #[tokio::test]
1910 async fn tka_reevaluate_applies_the_cross_peer_rotation_filter() {
1911 use ed25519_dalek::SigningKey;
1912 use ts_tka::NodeKeySignature;
1913
1914 let trusted = SigningKey::from_bytes(&[42u8; 32]);
1915 let authority = Authority::from_state(
1916 AumHash([0; 32]),
1917 State {
1918 keys: vec![Key {
1919 kind: KeyKind::Ed25519,
1920 votes: 1,
1921 public: trusted.verifying_key().to_bytes().to_vec(),
1922 }],
1923 },
1924 );
1925 // `stale` holds the pivot key with a valid Direct signature; `rotated` holds a key whose
1926 // rotation chain rotated the pivot key AWAY, which obsoletes `stale`.
1927 let pivot = SigningKey::from_bytes(&[9u8; 32]);
1928 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
1929 let stale = peer_node(
1930 "stale",
1931 pivot_pub,
1932 NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize(),
1933 );
1934 let new_key = [4u8; 32];
1935 let rotated = peer_node(
1936 "rotated",
1937 new_key,
1938 NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize(),
1939 );
1940
1941 // Both admitted while nothing is synced.
1942 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
1943 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
1944 stale.clone(),
1945 rotated.clone(),
1946 ]));
1947 assert_eq!(tracker.peer_db.peers().len(), 2, "no lock yet ⇒ admit all");
1948
1949 tka_tx.send_replace(Some(Arc::new(authority)));
1950 let evicted = tracker.tka_reevaluate_peer_db();
1951
1952 assert_eq!(
1953 evicted.len(),
1954 1,
1955 "only the rotation-obsolete peer is evicted"
1956 );
1957 assert!(
1958 tracker.peer_db.get(&rotated.node_key).is_some(),
1959 "the freshly-rotated peer stays"
1960 );
1961 assert!(
1962 tracker.peer_db.get(&stale.node_key).is_none(),
1963 "the peer whose key a rotation superseded is evicted, though its own signature verifies"
1964 );
1965 }
1966
1967 /// A `StateUpdate` carrying nothing but a `Full` peer set — the netmap shape the live-actor test
1968 /// publishes on the bus.
1969 fn netmap_with_peers(peers: Vec<Node>) -> ts_control::StateUpdate {
1970 ts_control::StateUpdate {
1971 session_handle: None,
1972 seq: 0,
1973 keep_alive: false,
1974 derp: None,
1975 node: None,
1976 peer_update: Some(ts_control::PeerUpdate::Full(peers)),
1977 peer_patches: Vec::new(),
1978 user_profiles: Vec::new(),
1979 ping: None,
1980 packetfilter: None,
1981 cap_grants: None,
1982 pop_browser_url: None,
1983 dial_plan: None,
1984 dns_config: None,
1985 ssh_policy: None,
1986 tka: None,
1987 online_change: Default::default(),
1988 peer_seen_change: Default::default(),
1989 }
1990 }
1991
1992 /// Poll a live [`PeerTracker`] until it holds exactly `want` peers, bounded by a timeout so a
1993 /// broken wiring fails the test instead of hanging the suite.
1994 async fn await_peer_count(tracker: &ActorRef<PeerTracker>, want: usize) -> Vec<Node> {
1995 let settled = tokio::time::timeout(std::time::Duration::from_secs(10), async {
1996 loop {
1997 let peers = tracker.ask(AllPeers).await.expect("peer tracker is alive");
1998 if peers.len() == want {
1999 return peers;
2000 }
2001 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2002 }
2003 })
2004 .await;
2005 settled.unwrap_or_else(|_| panic!("peer tracker never settled at {want} peer(s)"))
2006 }
2007
2008 /// End-to-end through the LIVE actor, which is the only thing that proves the wiring: the peer
2009 /// tracker watches its own enforcement cell, so the control runner's `send_replace` re-filters
2010 /// the peer db with no further netmap. If the watch task were never spawned (or the message not
2011 /// handled) the unsigned peer would stay admitted forever — a hole every `for_test` unit test
2012 /// above would still pass over, because they call the re-evaluation by hand.
2013 #[tokio::test]
2014 async fn tka_authority_change_refilters_through_the_live_actor() {
2015 use kameo::actor::Spawn as _;
2016
2017 let (authority, sig) = authority_and_valid_sig();
2018 let env = test_env();
2019 let (tka_tx, tka_rx) = watch::channel(None);
2020 let tracker = PeerTracker::spawn((env.clone(), tka_rx));
2021
2022 // Await one reply first: the actor's `on_start` (which registers it on the bus) has then
2023 // completed, so the netmap published below cannot race the subscription.
2024 assert!(
2025 tracker
2026 .ask(AllPeers)
2027 .await
2028 .expect("peer tracker started")
2029 .is_empty()
2030 );
2031
2032 let good = peer_node("good", NODE_KEY_BYTES, sig);
2033 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2034 env.publish(Arc::new(netmap_with_peers(vec![
2035 good.clone(),
2036 unsigned.clone(),
2037 ])))
2038 .await
2039 .expect("publish netmap");
2040
2041 // No lock synced ⇒ both peers land.
2042 await_peer_count(&tracker, 2).await;
2043
2044 // The control runner installs the verified authority. No netmap follows.
2045 tka_tx.send_replace(Some(Arc::new(authority)));
2046
2047 let peers = await_peer_count(&tracker, 1).await;
2048 assert_eq!(
2049 peers[0].stable_id, good.stable_id,
2050 "only the authorized peer survives the authority install"
2051 );
2052 }
2053
2054 /// Degenerate input: two DISTINCT nodes sharing one `stable_id` in a single `Full`, one with a
2055 /// valid signature and one unsigned, under an active lock. Each node is judged by its OWN verdict
2056 /// (the per-node `admits` vector), so the unsigned node is never admitted on the strength of its
2057 /// signed twin. The single-verify `Full` refactor keeps this per-node semantics (a stable_id-set
2058 /// alone would have admitted whichever node was upserted last). Malformed control input; asserted
2059 /// only to lock the verdict-per-node behavior against regression.
2060 #[tokio::test]
2061 async fn tka_full_duplicate_stable_id_judges_each_node_on_its_own_signature() {
2062 let (authority, sig) = authority_and_valid_sig();
2063 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2064
2065 // Both carry stable_id "dup"; the signed one authorizes NODE_KEY_BYTES, the other is unsigned
2066 // and uses a different node key. Order them unsigned-last so a last-writer-wins stable_id set
2067 // would (wrongly) leave the unsigned node's key in the db.
2068 let signed = peer_node("dup", NODE_KEY_BYTES, sig);
2069 let unsigned = peer_node("dup", [8u8; 32], vec![]);
2070 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2071 signed.clone(),
2072 unsigned.clone(),
2073 ]));
2074
2075 // The unsigned node's own verdict failed, so its key must NOT be present, regardless of the
2076 // shared stable_id. (The signed twin retained the stable_id; the db holds the signed key.)
2077 assert!(
2078 tracker.peer_db.get(&unsigned.node_key).is_none(),
2079 "a node whose own signature fails must not be admitted via a stable_id twin"
2080 );
2081 assert!(tracker.peer_db.get(&signed.node_key).is_some());
2082 }
2083
2084 /// Full-path consistency under two KEPT nodes sharing a `stable_id`: `peer_db.upsert` is
2085 /// last-writer-wins on `stable_id`, so the db ends holding exactly one node for that id (the last
2086 /// kept), and `retain` never evicts that just-upserted id (`retained_ids` contains the shared id
2087 /// because at least one of its nodes was kept). No lock here, so both nodes are "kept". This pins
2088 /// the published-state invariant the whole-surface audit flagged: `retain` and the upsert loop
2089 /// agree on the surviving stable_id. Malformed control input; asserted for robustness.
2090 #[tokio::test]
2091 async fn tka_full_duplicate_stable_id_both_kept_is_consistent() {
2092 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2093 let first = peer_node("dup", [1u8; 32], vec![]);
2094 let last = peer_node("dup", [2u8; 32], vec![]);
2095 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2096 first.clone(),
2097 last.clone(),
2098 ]));
2099
2100 // Exactly one db entry for the shared stable_id, holding the LAST node (upsert is
2101 // last-writer-wins on stable_id); the first node's key was transparently superseded.
2102 assert_eq!(
2103 tracker.peer_db.peers().len(),
2104 1,
2105 "one entry for the shared stable_id"
2106 );
2107 assert!(
2108 tracker.peer_db.get(&last.node_key).is_some(),
2109 "the db holds the last-upserted node for the shared id"
2110 );
2111 assert!(
2112 tracker.peer_db.get(&first.node_key).is_none(),
2113 "the first node's key was superseded by the last at the shared id"
2114 );
2115 }
2116
2117 /// A peer admitted in one `Full`, then in a later `Full` presenting a key that a co-resident
2118 /// peer's rotation chain has rotated away, is EVICTED — the cross-peer rotation filter applies on
2119 /// every resync, not only at first admission. Exercises the rotation filter through two
2120 /// sequential `Full` updates with real signing.
2121 #[tokio::test]
2122 async fn tka_full_rotation_obsolete_evicts_on_resync() {
2123 use ed25519_dalek::SigningKey;
2124 use ts_tka::NodeKeySignature;
2125
2126 let trusted = SigningKey::from_bytes(&[42u8; 32]);
2127 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2128 let authority = Authority::from_state(
2129 AumHash([0; 32]),
2130 State {
2131 keys: vec![Key {
2132 kind: KeyKind::Ed25519,
2133 votes: 1,
2134 public: trusted_pub.clone(),
2135 }],
2136 },
2137 );
2138 let pivot = SigningKey::from_bytes(&[9u8; 32]);
2139 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
2140
2141 // First Full: the soon-to-be-stale peer presents the pivot key with a valid Direct sig.
2142 let stale_sig = NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize();
2143 let stale_peer = peer_node("stale", pivot_pub, stale_sig);
2144 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2145 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![stale_peer.clone()]));
2146 assert!(
2147 tracker.peer_db.get(&stale_peer.node_key).is_some(),
2148 "the stale peer is admitted while no rotation has superseded it yet"
2149 );
2150
2151 // Second Full: a freshly-rotated peer (whose chain rotated AWAY the pivot key) joins, and the
2152 // stale peer is re-included. The rotation filter now obsoletes the pivot key ⇒ stale evicted.
2153 let new_key = [4u8; 32];
2154 let new_sig = NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize();
2155 let new_peer = peer_node("rotated", new_key, new_sig);
2156 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2157 new_peer.clone(),
2158 stale_peer.clone(),
2159 ]));
2160 assert!(
2161 tracker.peer_db.get(&new_peer.node_key).is_some(),
2162 "the freshly-rotated peer is admitted"
2163 );
2164 assert!(
2165 tracker.peer_db.get(&stale_peer.node_key).is_none(),
2166 "the stale peer is EVICTED on the resync once a rotation supersedes its key"
2167 );
2168 }
2169
2170 /// The empty-trusted-key-state brick-guard: an authority with no keys must NOT drop the whole
2171 /// netmap (a `ts_tka` invariant violation / replayer edge). A verified chain always carries ≥1
2172 /// key, so this never weakens a genuine lock — it only prevents a black-hole. Uses ≥2 peers
2173 /// (one signed, one unsigned) to prove it admits **all**, not accidentally just one.
2174 #[tokio::test]
2175 async fn tka_empty_keyset_authority_admits_all() {
2176 use ts_tka::{AumHash, Authority, State};
2177 let empty_auth = Authority::from_state(AumHash([0u8; 32]), State { keys: Vec::new() });
2178 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(empty_auth));
2179 let signed = peer_node("signed", [7u8; 32], vec![0xde, 0xad]);
2180 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2181 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2182 signed.clone(),
2183 unsigned.clone(),
2184 ]));
2185 assert_eq!(
2186 tracker.peer_db.peers().len(),
2187 2,
2188 "an empty-keyset authority must admit ALL peers (brick-guard), not enforce"
2189 );
2190 }
2191
2192 /// Signature-replay / `NodeKeyMismatch`: a structurally-valid signature that authorizes
2193 /// `NODE_KEY_BYTES` must NOT admit a DIFFERENT node key carrying that same signature blob. This is
2194 /// the highest-value bypass — if the sig↔node-key binding in `verify_signature` were dropped, this
2195 /// is the only test that would catch it (the other "bad" peers only flip a byte ⇒ `BadSignature`).
2196 #[tokio::test]
2197 async fn tka_active_rejects_valid_sig_for_wrong_node_key() {
2198 let (authority, sig) = authority_and_valid_sig();
2199 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2200
2201 // The signature authorizes NODE_KEY_BYTES; attach it to an imposter with a different key.
2202 let imposter = peer_node("imposter", [0x55u8; 32], sig);
2203 assert!(
2204 !tracker.tka_admits(&imposter),
2205 "a signature bound to one node key must not authorize a different node key"
2206 );
2207 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![imposter.clone()]));
2208 assert!(tracker.peer_db.get(&imposter.node_key).is_none());
2209 }
2210
2211 /// `UntrustedKey`: a signature produced by a well-formed Ed25519 key that is NOT in the
2212 /// authority's trusted-key state must be rejected — distinct from a tampered-byte `BadSignature`.
2213 #[tokio::test]
2214 async fn tka_active_rejects_sig_from_untrusted_key() {
2215 use ed25519_dalek::{Signer, SigningKey};
2216 let (authority, _sig) = authority_and_valid_sig();
2217 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2218
2219 // Sign a valid CBOR with a DIFFERENT key (not the one the authority trusts). The key_id in
2220 // the signature names this untrusted key, so `get_key` misses ⇒ UntrustedKey.
2221 let rogue = SigningKey::from_bytes(&[99u8; 32]);
2222 let rogue_pub = rogue.verifying_key().to_bytes().to_vec();
2223 let preimage = direct_sig_cbor(&NODE_KEY_BYTES, &rogue_pub, None);
2224 let sig_hash = ts_tka::aum_hash(&preimage).0;
2225 let signature = rogue.sign(&sig_hash).to_bytes().to_vec();
2226 let rogue_cbor = direct_sig_cbor(&NODE_KEY_BYTES, &rogue_pub, Some(&signature));
2227
2228 let peer = peer_node("rogue-signed", NODE_KEY_BYTES, rogue_cbor);
2229 assert!(
2230 !tracker.tka_admits(&peer),
2231 "a signature from a key outside the trusted set must be rejected"
2232 );
2233 // Drive the real upsert path too (match the sibling replay test's depth): an untrusted-key
2234 // signature must keep the peer out of the db, not merely fail the verdict in isolation.
2235 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
2236 assert!(tracker.peer_db.get(&peer.node_key).is_none());
2237 }
2238
2239 /// Bus-enable analogue for `Delta`: enforcement engaged via the watch cell must also gate a
2240 /// `Delta { upsert }` (not only `Full`). Closes the "authority arrived over the transport AND the
2241 /// next update is a Delta" combination.
2242 #[tokio::test]
2243 async fn tka_watch_enable_enforces_delta_upsert() {
2244 let (authority, sig) = authority_and_valid_sig();
2245 let (mut tracker, tka_tx) = PeerTracker::for_test(test_env(), None);
2246 tka_tx.send_replace(Some(Arc::new(authority)));
2247
2248 let good = peer_node("good", NODE_KEY_BYTES, sig);
2249 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
2250 tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
2251 remove: vec![],
2252 upsert: vec![good.clone(), unsigned.clone()],
2253 });
2254 assert!(tracker.peer_db.get(&good.node_key).is_some());
2255 assert!(
2256 tracker.peer_db.get(&unsigned.node_key).is_none(),
2257 "delta upsert under an active lock must drop the unsigned peer"
2258 );
2259 }
2260
2261 /// A `Delta` re-upsert of an ALREADY-ADMITTED peer whose signature is now invalid must EVICT the
2262 /// stale entry (revocation-via-delta), not leave it admitted. Go re-filters the whole netmap each
2263 /// response, so a now-unsigned peer would not survive there either.
2264 #[tokio::test]
2265 async fn tka_delta_reupsert_with_invalid_sig_evicts_existing() {
2266 let (authority, sig) = authority_and_valid_sig();
2267 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2268
2269 // Admit the signed peer.
2270 let good = peer_node("good", NODE_KEY_BYTES, sig.clone());
2271 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
2272 assert!(tracker.peer_db.get(&good.node_key).is_some());
2273
2274 // Re-upsert the SAME stable_id (now with no signature) via a delta ⇒ evicted, not retained.
2275 let revoked = peer_node("good", NODE_KEY_BYTES, vec![]);
2276 tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
2277 remove: vec![],
2278 upsert: vec![revoked],
2279 });
2280 assert!(
2281 tracker.peer_db.get(&good.node_key).is_none(),
2282 "a delta re-upsert that fails the lock must evict the previously-admitted peer"
2283 );
2284 }
2285
2286 #[tokio::test]
2287 async fn tka_full_resync_revocation_behavior() {
2288 // Revocation-on-resync: admit a peer, then re-include the SAME stable_id in a `Full` with a
2289 // now-invalid signature. Per the Logic review finding, the pre-fix `retain` kept the stale
2290 // (previously-admitted) entry because membership was decided purely by stable_id.
2291 //
2292 // FIXED (not merely documented): the `Full` `retain` now keys on `tka_admits`-passing
2293 // stable_ids, so a peer whose re-included signature no longer verifies under the active
2294 // authority is EVICTED. This test asserts eviction. The inactive (authority=None) path is
2295 // provably unchanged — `tka_admits` always returns `true` there, so the retained set equals
2296 // the set of re-included stable_ids exactly (see `tka_inactive_full_resync_keeps_*`).
2297 let (authority, sig) = authority_and_valid_sig();
2298 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2299
2300 // 1) Admit the peer with a valid signature via a real `Full`.
2301 let good = peer_node("revoked", NODE_KEY_BYTES, sig.clone());
2302 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
2303 assert_eq!(tracker.peer_db.peers().len(), 1);
2304 assert!(tracker.peer_db.get(&good.node_key).is_some());
2305
2306 // 2) Re-sync the SAME stable_id, but with a now-invalid signature (tamper trailing byte).
2307 let mut bad_sig = sig;
2308 let last = bad_sig.len() - 1;
2309 bad_sig[last] ^= 0xff;
2310 let revoked = peer_node("revoked", NODE_KEY_BYTES, bad_sig);
2311 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![revoked.clone()]));
2312
2313 // Eviction: the stale entry is dropped because its re-included signature fails the gate.
2314 assert_eq!(tracker.peer_db.peers().len(), 0);
2315 assert!(tracker.peer_db.get(&revoked.node_key).is_none());
2316 }
2317
2318 #[tokio::test]
2319 async fn tka_inactive_full_resync_keeps_reincluded_peer() {
2320 // Guard the inactive (authority=None) path against the revocation fix: with no authority,
2321 // a peer re-included in a `Full` survives regardless of its signature bytes — byte-for-byte
2322 // pre-TKA behavior, proving the `Full` `retain` change does not regress the always-taken
2323 // branch this wave.
2324 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2325
2326 let peer = peer_node("p", NODE_KEY_BYTES, vec![0xde, 0xad]);
2327 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
2328 assert_eq!(tracker.peer_db.peers().len(), 1);
2329
2330 // Re-sync the same stable_id with garbage signature bytes; inactive enforcement keeps it.
2331 let resynced = peer_node("p", NODE_KEY_BYTES, vec![0x00]);
2332 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![resynced.clone()]));
2333 assert_eq!(tracker.peer_db.peers().len(), 1);
2334 assert!(tracker.peer_db.get(&resynced.node_key).is_some());
2335 }
2336
2337 /// A `Patch` for a peer already in the netmap merges only the fields it carries — here new UDP
2338 /// endpoints and a new home DERP — leaving the rest of the node intact. This is the fix for
2339 /// dropped `peers_changed_patch`: without it the netmap keeps stale endpoints and the peer can
2340 /// never re-handshake after it moves.
2341 #[tokio::test]
2342 async fn patch_merges_endpoints_and_derp_into_existing_peer() {
2343 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2344
2345 // Seed a peer (id == 1, per `peer_node`) with no endpoints / no DERP.
2346 let peer = peer_node("mover", [1u8; 32], vec![]);
2347 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
2348 let (_pid, before) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2349 assert!(before.underlay_addresses.is_empty());
2350 assert!(before.derp_region.is_none());
2351
2352 // Patch in fresh reachability (the idle-peer-reconnect case).
2353 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
2354 let patch = ts_control::PeerChange {
2355 id: 1,
2356 derp_region: Some(ts_derp::RegionId(core::num::NonZeroU32::new(5).unwrap())),
2357 cap: None,
2358 cap_map: None,
2359 underlay_addresses: Some(vec![new_ep]),
2360 node_key: None,
2361 key_signature: None,
2362 disco_key: None,
2363 node_key_expiry: None,
2364 online: None,
2365 last_seen: None,
2366 };
2367 let (upserts, deletions) = tracker.apply_peer_patches(std::slice::from_ref(&patch));
2368
2369 assert_eq!(upserts.len(), 1);
2370 assert_eq!(deletions.len(), 0);
2371 // Same peer, now carrying the patched endpoint + DERP; node key untouched.
2372 assert_eq!(tracker.peer_db.peers().len(), 1);
2373 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2374 assert_eq!(after.underlay_addresses, vec![new_ep]);
2375 assert_eq!(
2376 after.derp_region,
2377 Some(ts_derp::RegionId(core::num::NonZeroU32::new(5).unwrap()))
2378 );
2379 assert_eq!(after.node_key, peer.node_key);
2380 }
2381
2382 /// Regression for `tsr-5u0`: when a whole-node set (`Delta`/`Full`) and a patch co-occur in one
2383 /// response, the patch is applied *on top of* the node the set just upserted — mirroring the
2384 /// handler's apply-order (peer set first, then `peer_patches`). Before the fix the patch shared
2385 /// the single `peer_update` slot and the co-occurring set silently dropped it, so a peer brought
2386 /// in by the delta kept stale (empty) reachability.
2387 #[tokio::test]
2388 async fn patch_applies_on_top_of_co_occurring_delta() {
2389 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2390
2391 // The whole-node delta upserts a brand-new peer (id == 1) with no reachability.
2392 let peer = peer_node("mover", [1u8; 32], vec![]);
2393 let (set_upserts, _) = tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
2394 upsert: vec![peer.clone()],
2395 remove: vec![],
2396 });
2397 assert_eq!(set_upserts.len(), 1, "delta upserts the new peer");
2398
2399 // The patch from the SAME response then sets that peer's endpoints + DERP. This is exactly
2400 // the consumer order the handler runs (apply_peer_update then apply_peer_patches).
2401 let new_ep: std::net::SocketAddr = "203.0.113.7:41641".parse().unwrap();
2402 let patch = ts_control::PeerChange {
2403 id: 1,
2404 derp_region: Some(ts_derp::RegionId(core::num::NonZeroU32::new(7).unwrap())),
2405 cap: None,
2406 cap_map: None,
2407 underlay_addresses: Some(vec![new_ep]),
2408 node_key: None,
2409 key_signature: None,
2410 disco_key: None,
2411 node_key_expiry: None,
2412 online: None,
2413 last_seen: None,
2414 };
2415 let (patch_upserts, patch_deletions) =
2416 tracker.apply_peer_patches(std::slice::from_ref(&patch));
2417
2418 assert_eq!(
2419 patch_upserts.len(),
2420 1,
2421 "patch re-upserts the just-added peer"
2422 );
2423 assert_eq!(patch_deletions.len(), 0);
2424 // The peer added by the delta now carries the patched reachability — the patch was NOT lost.
2425 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2426 assert_eq!(after.underlay_addresses, vec![new_ep]);
2427 assert_eq!(
2428 after.derp_region,
2429 Some(ts_derp::RegionId(core::num::NonZeroU32::new(7).unwrap()))
2430 );
2431 }
2432
2433 /// A `Patch` whose node id is not in the current netmap is ignored (the wire contract: a patch
2434 /// never creates a node). No upsert, no deletion, peer set unchanged.
2435 #[tokio::test]
2436 async fn patch_for_unknown_node_is_ignored() {
2437 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2438 let known = peer_node("known", [1u8; 32], vec![]); // id == 1
2439 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![known]));
2440
2441 let patch = ts_control::PeerChange {
2442 id: 999, // not in the netmap
2443 derp_region: None,
2444 cap: None,
2445 cap_map: None,
2446 underlay_addresses: Some(vec!["198.51.100.9:1".parse().unwrap()]),
2447 node_key: None,
2448 key_signature: None,
2449 disco_key: None,
2450 node_key_expiry: None,
2451 online: None,
2452 last_seen: None,
2453 };
2454 let (upserts, deletions) = tracker.apply_peer_patches(std::slice::from_ref(&patch));
2455
2456 assert_eq!(upserts.len(), 0);
2457 assert_eq!(deletions.len(), 0);
2458 assert_eq!(tracker.peer_db.peers().len(), 1);
2459 assert!(tracker.peer_db.get(&(999 as ts_control::NodeId)).is_none());
2460 }
2461
2462 /// An expiry-only `Patch` updates `node_key_expiry` on the matching peer (Go
2463 /// `PeerChange.KeyExpiry`), rather than being silently dropped until the next full resync.
2464 #[tokio::test]
2465 async fn patch_updates_node_key_expiry() {
2466 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2467 let peer = peer_node("expiring", [1u8; 32], vec![]); // id == 1, node_key_expiry: None
2468 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
2469
2470 let expiry = "2027-01-01T00:00:00Z"
2471 .parse::<chrono::DateTime<chrono::Utc>>()
2472 .unwrap();
2473 let patch = ts_control::PeerChange {
2474 id: 1,
2475 derp_region: None,
2476 cap: None,
2477 cap_map: None,
2478 underlay_addresses: None,
2479 node_key: None,
2480 key_signature: None,
2481 disco_key: None,
2482 node_key_expiry: Some(expiry),
2483 online: None,
2484 last_seen: None,
2485 };
2486 tracker.apply_peer_patches(std::slice::from_ref(&patch));
2487
2488 let (_pid, after) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2489 assert_eq!(after.node_key_expiry, Some(expiry));
2490 }
2491
2492 /// Channel B: a `PeerChange.online` patch flips a peer's online state without a full node.
2493 #[tokio::test]
2494 async fn patch_updates_online() {
2495 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2496 let peer = peer_node("p", [1u8; 32], vec![]); // id == 1, online: None
2497 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
2498 assert_eq!(
2499 tracker
2500 .peer_db
2501 .get(&(1 as ts_control::NodeId))
2502 .unwrap()
2503 .1
2504 .online,
2505 None
2506 );
2507
2508 let mut patch = ts_control::PeerChange {
2509 id: 1,
2510 derp_region: None,
2511 cap: None,
2512 cap_map: None,
2513 underlay_addresses: None,
2514 node_key: None,
2515 key_signature: None,
2516 disco_key: None,
2517 node_key_expiry: None,
2518 online: Some(true),
2519 last_seen: None,
2520 };
2521 tracker.apply_peer_patches(std::slice::from_ref(&patch));
2522 assert_eq!(
2523 tracker
2524 .peer_db
2525 .get(&(1 as ts_control::NodeId))
2526 .unwrap()
2527 .1
2528 .online,
2529 Some(true),
2530 "PeerChange.online=Some(true) marks the peer online"
2531 );
2532
2533 // A subsequent patch flips it offline.
2534 patch.online = Some(false);
2535 tracker.apply_peer_patches(std::slice::from_ref(&patch));
2536 assert_eq!(
2537 tracker
2538 .peer_db
2539 .get(&(1 as ts_control::NodeId))
2540 .unwrap()
2541 .1
2542 .online,
2543 Some(false)
2544 );
2545 }
2546
2547 /// Channel C/D (Go `map.go:updatePeersStateFromResponse`): `online_change` is the sole driver of
2548 /// `online`; `peer_seen_change` is the sole driver of `last_seen` (true ⇒ now, false ⇒ cleared)
2549 /// and must NEVER touch `online`. Both apply to a peer already in the netmap and ignore unknown
2550 /// ids. This pins the fix for the prior bug where channel D wrote `online=false` (conflating
2551 /// "not seen recently" with "offline" — distinct signals in Go).
2552 #[tokio::test]
2553 async fn liveness_change_maps_apply_online() {
2554 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2555 let peer = peer_node("p", [1u8; 32], vec![]); // id == 1
2556 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
2557 // A fixed timestamp (chrono is built without its `clock` feature, so no `Utc::now()`).
2558 let now = chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap();
2559
2560 // Channel C: online_change sets online=true.
2561 let mut online_change = std::collections::BTreeMap::new();
2562 online_change.insert(1 as ts_control::NodeId, true);
2563 online_change.insert(999 as ts_control::NodeId, true); // unknown id — ignored
2564 let changed = tracker.apply_liveness_changes(&online_change, &Default::default(), now);
2565 assert!(changed);
2566 assert_eq!(
2567 tracker
2568 .peer_db
2569 .get(&(1 as ts_control::NodeId))
2570 .unwrap()
2571 .1
2572 .online,
2573 Some(true)
2574 );
2575
2576 // Channel D: peer_seen_change=true sets last_seen=now and leaves online UNTOUCHED.
2577 let mut seen_true = std::collections::BTreeMap::new();
2578 seen_true.insert(1 as ts_control::NodeId, true);
2579 let changed = tracker.apply_liveness_changes(&Default::default(), &seen_true, now);
2580 assert!(changed);
2581 {
2582 let (_id, node) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2583 assert_eq!(
2584 node.last_seen,
2585 Some(now),
2586 "peer_seen_change=true sets last_seen=now"
2587 );
2588 assert_eq!(
2589 node.online,
2590 Some(true),
2591 "channel D must NOT touch online (still true from channel C)"
2592 );
2593 }
2594
2595 // Channel D: peer_seen_change=false clears last_seen, still leaving online untouched.
2596 let mut seen_false = std::collections::BTreeMap::new();
2597 seen_false.insert(1 as ts_control::NodeId, false);
2598 let changed = tracker.apply_liveness_changes(&Default::default(), &seen_false, now);
2599 assert!(changed);
2600 {
2601 let (_id, node) = tracker.peer_db.get(&(1 as ts_control::NodeId)).unwrap();
2602 assert_eq!(
2603 node.last_seen, None,
2604 "peer_seen_change=false clears last_seen"
2605 );
2606 assert_eq!(node.online, Some(true), "channel D must NOT mark offline");
2607 }
2608 assert_eq!(
2609 tracker.peer_db.peers().len(),
2610 1,
2611 "the node is retained, not removed"
2612 );
2613
2614 // No-op when nothing matches / changes.
2615 assert!(!tracker.apply_liveness_changes(&Default::default(), &Default::default(), now));
2616 }
2617
2618 /// Security: a `Patch` that rotates the node key must re-satisfy the tailnet-lock authority,
2619 /// exactly like a `Delta` upsert. A key-rotation patch whose new signature does NOT verify
2620 /// evicts the peer (fail-closed) rather than leaving a now-unverified entry — closing what would
2621 /// otherwise be a trust-enforcement bypass via the patch path.
2622 #[tokio::test]
2623 async fn patch_key_rotation_failing_tka_evicts_peer() {
2624 let (authority, sig) = authority_and_valid_sig();
2625 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2626
2627 // Admit a correctly-signed peer (id == 1).
2628 let good = peer_node("rotator", NODE_KEY_BYTES, sig.clone());
2629 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
2630 assert_eq!(tracker.peer_db.peers().len(), 1);
2631
2632 // Patch a new node key whose signature is garbage under the active authority.
2633 let patch = ts_control::PeerChange {
2634 id: 1,
2635 derp_region: None,
2636 cap: None,
2637 cap_map: None,
2638 underlay_addresses: None,
2639 node_key: Some([0x33u8; 32].into()),
2640 key_signature: Some(vec![0x00, 0x01, 0x02]),
2641 disco_key: None,
2642 node_key_expiry: None,
2643 online: None,
2644 last_seen: None,
2645 };
2646 let (upserts, deletions) = tracker.apply_peer_patches(std::slice::from_ref(&patch));
2647
2648 assert_eq!(upserts.len(), 0);
2649 assert_eq!(deletions.len(), 1);
2650 assert_eq!(tracker.peer_db.peers().len(), 0);
2651 }
2652
2653 /// A node's `user_id` joins against the accumulated UserProfiles table to resolve the owning
2654 /// user's login name in `WhoIs.user`. With no matching profile, `user` is `None` (the
2655 /// pre-existing behavior); once a profile arrives, the same node resolves to its login. This
2656 /// proves the accumulate-then-join path the netmap handler builds.
2657 fn profile(id: ts_control::UserId, login: &str) -> ts_control::UserProfile {
2658 ts_control::UserProfile {
2659 id,
2660 login_name: login.to_string(),
2661 display_name: None,
2662 }
2663 }
2664
2665 #[tokio::test]
2666 async fn whois_resolves_user_from_accumulated_profiles() {
2667 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2668
2669 // A peer owned by user id 42 at 100.64.0.1 (the peer_node fixture's address).
2670 let mut peer = peer_node("p", NODE_KEY_BYTES, Vec::new());
2671 peer.user_id = 42;
2672 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
2673 let addr = "100.64.0.1:0".parse().unwrap();
2674
2675 // No profile yet: the node resolves but its owner is unknown.
2676 let who = tracker.whois_opt(addr).expect("peer is known");
2677 assert_eq!(who.user, None);
2678
2679 // Profile for a DIFFERENT user must not match.
2680 tracker
2681 .user_profiles
2682 .insert(7, profile(7, "someone-else@example.com"));
2683 assert_eq!(tracker.whois_opt(addr).unwrap().user, None);
2684
2685 // The owning user's profile arrives (as the netmap handler would accumulate it): now the
2686 // login resolves.
2687 tracker
2688 .user_profiles
2689 .insert(42, profile(42, "alice@example.com"));
2690 assert_eq!(
2691 tracker.whois_opt(addr).unwrap().user,
2692 Some("alice@example.com".to_string())
2693 );
2694 }
2695
2696 /// `UserProfile::best_label` prefers the login name, falling back to display name, else `None`.
2697 #[test]
2698 fn user_profile_best_label_prefers_login() {
2699 assert_eq!(
2700 profile(1, "alice@example.com").best_label(),
2701 Some("alice@example.com".to_string())
2702 );
2703 let display_only = ts_control::UserProfile {
2704 id: 2,
2705 login_name: String::new(),
2706 display_name: Some("Bob".to_string()),
2707 };
2708 assert_eq!(display_only.best_label(), Some("Bob".to_string()));
2709 let empty = ts_control::UserProfile {
2710 id: 3,
2711 login_name: String::new(),
2712 display_name: None,
2713 };
2714 assert_eq!(empty.best_label(), None);
2715 }
2716
2717 // ----- tsr-jo1: RotationTracker (Go ipnlocal.rotationTracker.obsoleteKeys) -----
2718
2719 /// A `RotationDetails` for a `Direct`-rooted chain with the given prior keys + wrapping key.
2720 fn rot_details(
2721 prev: &[&[u8]],
2722 wrapping: &[u8],
2723 kind: ts_tka::SigKind,
2724 ) -> ts_tka::RotationDetails {
2725 ts_tka::RotationDetails {
2726 prev_node_keys: prev.iter().map(|p| p.to_vec()).collect(),
2727 initial_sig_kind: kind,
2728 initial_wrapping_pubkey: wrapping.to_vec(),
2729 }
2730 }
2731
2732 /// Rule 1: every prior node key named by any rotation chain is obsolete, regardless of the
2733 /// chain's root kind (Go's ungated `obsolete.AddSlice(d.PrevNodeKeys)`).
2734 #[test]
2735 fn rotation_tracker_prev_keys_always_obsolete() {
2736 let mut t = RotationTracker::default();
2737 // A Direct-rooted chain that rotated away OLD1, and a Credential-rooted one that rotated OLD2.
2738 t.add(
2739 b"newA".to_vec(),
2740 &rot_details(&[b"OLD1"], b"wrapA", ts_tka::SigKind::Direct),
2741 );
2742 t.add(
2743 b"newB".to_vec(),
2744 &rot_details(&[b"OLD2"], b"wrapB", ts_tka::SigKind::Credential),
2745 );
2746 let obsolete = t.obsolete_keys();
2747 assert!(
2748 obsolete.contains(b"OLD1".as_slice()),
2749 "Direct chain's prior key obsolete"
2750 );
2751 assert!(
2752 obsolete.contains(b"OLD2".as_slice()),
2753 "Credential chain's prior key obsolete too (rule 1 is ungated)"
2754 );
2755 // The current keys themselves are not obsolete (only one peer per wrapping key here).
2756 assert!(!obsolete.contains(b"newA".as_slice()));
2757 assert!(!obsolete.contains(b"newB".as_slice()));
2758 }
2759
2760 /// Rule 2: among `Direct`-rooted chains sharing a wrapping key, only the longest survives; the
2761 /// shorter (older) clone's key is obsolete.
2762 #[test]
2763 fn rotation_tracker_unequal_chain_keeps_longest() {
2764 let mut t = RotationTracker::default();
2765 // Same wrapping key; "long" has 2 prior keys, "short" has 1 ⇒ "short" is the older clone.
2766 t.add(
2767 b"long".to_vec(),
2768 &rot_details(&[b"p1", b"p2"], b"wrap", ts_tka::SigKind::Direct),
2769 );
2770 t.add(
2771 b"short".to_vec(),
2772 &rot_details(&[b"q1"], b"wrap", ts_tka::SigKind::Direct),
2773 );
2774 let obsolete = t.obsolete_keys();
2775 assert!(
2776 obsolete.contains(b"short".as_slice()),
2777 "the shorter-chain clone is obsolete"
2778 );
2779 assert!(
2780 !obsolete.contains(b"long".as_slice()),
2781 "the longest-chain peer survives"
2782 );
2783 }
2784
2785 /// Rule 2 tie: two `Direct`-rooted chains sharing a wrapping key with EQUAL chain length cannot
2786 /// be disambiguated ⇒ BOTH are dropped (Go's safety branch).
2787 #[test]
2788 fn rotation_tracker_equal_chain_drops_both() {
2789 let mut t = RotationTracker::default();
2790 t.add(
2791 b"cloneA".to_vec(),
2792 &rot_details(&[b"p1"], b"wrap", ts_tka::SigKind::Direct),
2793 );
2794 t.add(
2795 b"cloneB".to_vec(),
2796 &rot_details(&[b"p2"], b"wrap", ts_tka::SigKind::Direct),
2797 );
2798 let obsolete = t.obsolete_keys();
2799 assert!(
2800 obsolete.contains(b"cloneA".as_slice()),
2801 "tied clone A dropped"
2802 );
2803 assert!(
2804 obsolete.contains(b"cloneB".as_slice()),
2805 "tied clone B dropped"
2806 );
2807 }
2808
2809 /// `Credential`-rooted chains sharing a wrapping key are EXEMPT from rule 2 (reusable-authkey
2810 /// carve-out): both are kept even with equal chain length.
2811 #[test]
2812 fn rotation_tracker_credential_root_clones_both_kept() {
2813 let mut t = RotationTracker::default();
2814 t.add(
2815 b"credA".to_vec(),
2816 &rot_details(&[b"p1"], b"wrap", ts_tka::SigKind::Credential),
2817 );
2818 t.add(
2819 b"credB".to_vec(),
2820 &rot_details(&[b"p2"], b"wrap", ts_tka::SigKind::Credential),
2821 );
2822 let obsolete = t.obsolete_keys();
2823 assert!(
2824 !obsolete.contains(b"credA".as_slice()),
2825 "credential-rooted clone A kept"
2826 );
2827 assert!(
2828 !obsolete.contains(b"credB".as_slice()),
2829 "credential-rooted clone B kept"
2830 );
2831 }
2832
2833 /// A peer that another chain already rotated away does not also act as a surviving clone: it is
2834 /// removed from its wrapping-key group before the longest-survivor pick (Go's `DeleteFunc`).
2835 #[test]
2836 fn rotation_tracker_already_obsolete_peer_not_a_survivor() {
2837 let mut t = RotationTracker::default();
2838 // "victim" is rotated away by "rotator" (different wrapping key), AND shares wrapping key
2839 // "w" with "other". Because "victim" is already obsolete, only "other" is in play for "w" and
2840 // survives (no spurious tie-drop of "other").
2841 t.add(
2842 b"rotator".to_vec(),
2843 &rot_details(&[b"victim"], b"wRot", ts_tka::SigKind::Direct),
2844 );
2845 t.add(
2846 b"victim".to_vec(),
2847 &rot_details(&[b"x"], b"w", ts_tka::SigKind::Direct),
2848 );
2849 t.add(
2850 b"other".to_vec(),
2851 &rot_details(&[b"y"], b"w", ts_tka::SigKind::Direct),
2852 );
2853 let obsolete = t.obsolete_keys();
2854 assert!(
2855 obsolete.contains(b"victim".as_slice()),
2856 "victim rotated away by rotator"
2857 );
2858 assert!(
2859 !obsolete.contains(b"other".as_slice()),
2860 "other survives — victim was removed from the group before the tie check"
2861 );
2862 }
2863
2864 /// Empty tracker (no rotation-signed peers) ⇒ no obsolete keys (the non-rotation netmap path).
2865 #[test]
2866 fn rotation_tracker_empty_is_noop() {
2867 let t = RotationTracker::default();
2868 assert!(t.obsolete_keys().is_empty());
2869 }
2870
2871 /// End-to-end through the real `Full` path: a peer presenting a freshly-rotated key (a Rotation
2872 /// chain) is admitted, while a second peer still presenting the rotated-AWAY pivot key — even with
2873 /// that key's own still-valid Direct signature — is DROPPED by the cross-peer rotation filter.
2874 /// This is the gap closed here: Go `tkaFilterNetmapLocked` drops the stale clone; we used to admit
2875 /// it. Uses real `ts_tka` signing (`sign_direct` + `sign_rotation`) so the whole
2876 /// verify → details → filter pipeline runs.
2877 ///
2878 /// Construction: the trusted key signs an inner `Direct` over the PIVOT keypair's public key; the
2879 /// pivot key then signs an outer `Rotation` authorizing `new_key`. That chain's `prev_node_keys`
2880 /// names the pivot pubkey — so a peer presenting the pivot pubkey as its node key is the
2881 /// rotated-away key the filter must drop.
2882 #[tokio::test]
2883 async fn tka_full_drops_rotated_away_key_e2e() {
2884 use ed25519_dalek::SigningKey;
2885 use ts_tka::NodeKeySignature;
2886
2887 let trusted = SigningKey::from_bytes(&[42u8; 32]);
2888 let trusted_pub = trusted.verifying_key().to_bytes().to_vec();
2889 let authority = Authority::from_state(
2890 AumHash([0; 32]),
2891 State {
2892 keys: vec![Key {
2893 kind: KeyKind::Ed25519,
2894 votes: 1,
2895 public: trusted_pub.clone(),
2896 }],
2897 },
2898 );
2899
2900 // The rotation pivot: a keypair whose public key the inner Direct authorizes and whose
2901 // private key signs the outer rotation wrap. This pivot pubkey IS the key being rotated away.
2902 let pivot = SigningKey::from_bytes(&[9u8; 32]);
2903 let pivot_pub: [u8; 32] = pivot.verifying_key().to_bytes();
2904
2905 let new_key = [4u8; 32]; // the freshly-rotated node key
2906
2907 // Fresh peer: a Rotation chain authorizing `new_key`, inner Direct over the pivot signed by
2908 // trusted, outer wrap signed by the pivot. Its prev_node_keys names `pivot_pub`.
2909 let new_sig = NodeKeySignature::sign_rotation(&new_key, &trusted, &pivot).serialize();
2910 let new_peer = peer_node("rotated", new_key, new_sig);
2911
2912 // Stale peer: still presents the pivot pubkey (the rotated-away key) with its own valid
2913 // Direct signature — valid in isolation, but obsoleted by the fresh peer's rotation chain.
2914 let stale_sig = NodeKeySignature::sign_direct(&pivot_pub, &trusted).serialize();
2915 let stale_peer = peer_node("stale", pivot_pub, stale_sig);
2916
2917 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), Some(authority));
2918 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![
2919 new_peer.clone(),
2920 stale_peer.clone(),
2921 ]));
2922
2923 assert!(
2924 tracker.peer_db.get(&new_peer.node_key).is_some(),
2925 "the freshly-rotated peer is admitted"
2926 );
2927 assert!(
2928 tracker.peer_db.get(&stale_peer.node_key).is_none(),
2929 "the peer presenting the rotated-away key is dropped (Go tkaFilterNetmapLocked)"
2930 );
2931 }
2932}
2933
2934#[cfg(test)]
2935mod tsmp_disco_key_tests {
2936 //! Receive side of the TSMP disco-key advertisement, at the point the key is *learned*.
2937 //!
2938 //! These exercise [`PeerTracker::learn_disco_key`] — the fork's stand-in for Go
2939 //! `magicsock.Conn.HandleDiscoKeyAdvertisement` — which is the single place an advertisement
2940 //! reaches peer state. The wire decode and the "consumed, not delivered" drop are covered in
2941 //! `ts_packet::tsmp` and `ts_dataplane` respectively.
2942
2943 use ts_keys::DiscoPublicKey;
2944
2945 use super::{
2946 tka_tests::{peer_node, test_env},
2947 *,
2948 };
2949
2950 /// The key a peer advertises, and a second one for the re-advertise case.
2951 const ADVERTISED: [u8; 32] = [0xa5u8; 32];
2952 const READVERTISED: [u8; 32] = [0x5au8; 32];
2953 /// The (staler) key control has for that same peer, and the one control eventually catches up
2954 /// to.
2955 const FROM_CONTROL: [u8; 32] = [0xc0u8; 32];
2956 const CONTROL_CAUGHT_UP: [u8; 32] = [0x0cu8; 32];
2957
2958 /// The node key of the single peer these tests use.
2959 const PEER_NODE_KEY: [u8; 32] = [1u8; 32];
2960
2961 /// A tracker holding one peer with no disco key yet, plus that peer's [`PeerId`].
2962 fn tracker_with_peer() -> (PeerTracker, PeerId) {
2963 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2964 let node = peer_node("peer", PEER_NODE_KEY, Vec::new());
2965 let id = tracker.peer_db.upsert(&node);
2966 (tracker, id)
2967 }
2968
2969 /// The peer as CONTROL describes it: the same node, carrying whatever disco key the netmap says
2970 /// it has (`None` for a peer control has no disco key for at all).
2971 fn node_from_control(disco_key: Option<[u8; 32]>) -> Node {
2972 let mut node = peer_node("peer", PEER_NODE_KEY, Vec::new());
2973 node.disco_key = disco_key.map(DiscoPublicKey::from);
2974 node
2975 }
2976
2977 /// A netmap `Full` carrying just this peer, as control currently describes it.
2978 fn control_full(disco_key: Option<[u8; 32]>) -> ts_control::PeerUpdate {
2979 ts_control::PeerUpdate::Full(vec![node_from_control(disco_key)])
2980 }
2981
2982 /// A tracker whose single peer arrived through the netmap carrying `disco_key`, exactly as the
2983 /// actor's handler applies it. Returns the peer's [`PeerId`] too.
2984 fn tracker_with_control_peer(disco_key: Option<[u8; 32]>) -> (PeerTracker, PeerId) {
2985 let (mut tracker, _tka_tx) = PeerTracker::for_test(test_env(), None);
2986 let node = node_from_control(disco_key);
2987 tracker.apply_peer_update(&control_full(disco_key));
2988 let id = tracker
2989 .peer_db
2990 .has(&node.node_key)
2991 .expect("control delivered it");
2992 (tracker, id)
2993 }
2994
2995 /// The disco key the peer db currently holds for `peer` — the effective key every direct-path
2996 /// consumer resolves against.
2997 fn effective_key(tracker: &PeerTracker, peer: PeerId) -> Option<DiscoPublicKey> {
2998 tracker
2999 .peer_db
3000 .get(&peer)
3001 .expect("peer still present")
3002 .1
3003 .disco_key
3004 }
3005
3006 /// The happy path: an advertised key is applied to the peer AND lands in the disco index, which
3007 /// is what the direct-path machinery (`direct::DiscoPeerLookup`) reads. Re-advertising the same
3008 /// key is a no-op; advertising a different one replaces it, retracting the old index entry.
3009 #[tokio::test]
3010 async fn advertisement_learns_the_peers_disco_key() {
3011 let (mut tracker, peer) = tracker_with_peer();
3012 let key = DiscoPublicKey::from(ADVERTISED);
3013
3014 assert!(
3015 tracker.learn_disco_key(peer, key),
3016 "a first advertisement changes the peer db"
3017 );
3018 assert_eq!(
3019 tracker
3020 .peer_db
3021 .get(&peer)
3022 .expect("peer still present")
3023 .1
3024 .disco_key,
3025 Some(key),
3026 "the advertised disco key is learned"
3027 );
3028 assert_eq!(
3029 tracker.peer_db.has(&key),
3030 Some(peer),
3031 "and is reachable through the disco index the direct path resolves against"
3032 );
3033
3034 assert!(
3035 !tracker.learn_disco_key(peer, key),
3036 "re-advertising the same key is a no-op (Go counts it 'unchanged' and returns)"
3037 );
3038
3039 let rotated = DiscoPublicKey::from(READVERTISED);
3040 assert!(tracker.learn_disco_key(peer, rotated));
3041 assert_eq!(
3042 tracker
3043 .peer_db
3044 .get(&peer)
3045 .expect("peer still present")
3046 .1
3047 .disco_key,
3048 Some(rotated),
3049 "a later advertisement replaces the key without a netmap update"
3050 );
3051 assert_eq!(tracker.peer_db.has(&rotated), Some(peer));
3052 assert_eq!(
3053 tracker.peer_db.has(&key),
3054 None,
3055 "the superseded key no longer resolves to the peer"
3056 );
3057 }
3058
3059 /// The refusals, each of which must leave the peer db untouched: the zero key is never learned,
3060 /// and an advertisement never creates a peer.
3061 #[tokio::test]
3062 async fn refused_advertisements_change_nothing() {
3063 let (mut tracker, peer) = tracker_with_peer();
3064
3065 assert!(
3066 !tracker.learn_disco_key(peer, DiscoPublicKey::from([0u8; 32])),
3067 "the zero key is never learned"
3068 );
3069 assert_eq!(
3070 tracker
3071 .peer_db
3072 .get(&peer)
3073 .expect("peer still present")
3074 .1
3075 .disco_key,
3076 None,
3077 "a zero-key advertisement must not bind the peer to an unusable key"
3078 );
3079
3080 // An advertisement for a peer control has never told us about. Go logs "endpoint not found
3081 // for node" and returns; it must not conjure a peer into existence.
3082 let unknown = PeerId(4242);
3083 assert_eq!(tracker.peer_db.get(&unknown), None, "precondition");
3084 assert!(
3085 !tracker.learn_disco_key(unknown, DiscoPublicKey::from(ADVERTISED)),
3086 "an advertisement for an unknown peer is ignored"
3087 );
3088 assert_eq!(
3089 tracker.peer_db.peers().len(),
3090 1,
3091 "an advertisement never creates a peer — only control does"
3092 );
3093 assert_eq!(
3094 tracker.peer_db.has(&DiscoPublicKey::from(ADVERTISED)),
3095 None,
3096 "and never indexes a key against a peer that does not exist"
3097 );
3098 }
3099
3100 /// The feature's motivating case, end to end: the peer told us a key control has not caught up
3101 /// with, and then control polls again with the SAME stale key it had before. The advertisement
3102 /// must survive.
3103 ///
3104 /// Go keeps the two keys apart on the endpoint (`endpointDisco.controlKey` /
3105 /// `tsmpKey`), and `updateFromNode` only rewrites the control side when control's key actually
3106 /// changed — so a netmap restating the old key never touches the active TSMP key. With a single
3107 /// field the next map poll silently reverted the peer to control's stale key, which is precisely
3108 /// the state the advertisement exists to escape.
3109 #[tokio::test]
3110 async fn netmap_restating_controls_stale_key_keeps_the_tsmp_key() {
3111 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3112 let advertised = DiscoPublicKey::from(ADVERTISED);
3113 assert_eq!(
3114 effective_key(&tracker, peer),
3115 Some(DiscoPublicKey::from(FROM_CONTROL)),
3116 "precondition: the peer starts on the key control gave us"
3117 );
3118
3119 assert!(tracker.learn_disco_key(peer, advertised));
3120 assert_eq!(effective_key(&tracker, peer), Some(advertised));
3121
3122 // Control polls again, still behind: a `Full` resync, then a `Delta` re-upsert, both
3123 // carrying the key control already sent.
3124 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)));
3125 assert_eq!(
3126 effective_key(&tracker, peer),
3127 Some(advertised),
3128 "a Full restating control's stale key must not undo the TSMP-learned key"
3129 );
3130 tracker.apply_peer_update(&ts_control::PeerUpdate::Delta {
3131 upsert: vec![node_from_control(Some(FROM_CONTROL))],
3132 remove: vec![],
3133 });
3134 assert_eq!(
3135 effective_key(&tracker, peer),
3136 Some(advertised),
3137 "and neither must a Delta re-upsert of the same node"
3138 );
3139 assert_eq!(
3140 tracker.peer_db.has(&advertised),
3141 Some(peer),
3142 "the direct path still resolves the peer by the key it advertised"
3143 );
3144 assert_eq!(
3145 tracker.peer_db.has(&DiscoPublicKey::from(FROM_CONTROL)),
3146 None,
3147 "and control's superseded key does not resolve to it"
3148 );
3149
3150 // Control finally changes its mind. A genuinely NEW control key wins, exactly as it does
3151 // upstream (`updateDiscoKey` clears `tsmpActive` for a non-zero control key).
3152 tracker.apply_peer_update(&control_full(Some(CONTROL_CAUGHT_UP)));
3153 assert_eq!(
3154 effective_key(&tracker, peer),
3155 Some(DiscoPublicKey::from(CONTROL_CAUGHT_UP)),
3156 "control changing the key is authoritative again"
3157 );
3158 }
3159
3160 /// An advertisement that merely restates the key control already gave us is still *new*
3161 /// information — it is the peer itself confirming the key — so Go records it as the TSMP key and
3162 /// makes it active. Its "unchanged" early return compares `epDisco.keyFromTSMP()`, the
3163 /// TSMP-learned key specifically, never the effective one.
3164 ///
3165 /// The observable consequence, asserted here: once the peer has confirmed the key, control
3166 /// dropping it (a netmap node with no disco key) leaves the confirmed key in place instead of
3167 /// blinding the direct path.
3168 #[tokio::test]
3169 async fn advertisement_restating_controls_key_is_recorded_as_the_tsmp_key() {
3170 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3171 let key = DiscoPublicKey::from(FROM_CONTROL);
3172
3173 assert!(
3174 tracker.learn_disco_key(peer, key),
3175 "an advertisement of the key control already sent is recorded, not dropped"
3176 );
3177 assert_eq!(
3178 tracker
3179 .endpoint_disco
3180 .get(&PEER_NODE_KEY.into())
3181 .and_then(EndpointDisco::key_from_tsmp),
3182 Some(key),
3183 "it lands in the TSMP slot (Go epDisco.tsmpKey), not only in control's"
3184 );
3185 assert!(
3186 !tracker.learn_disco_key(peer, key),
3187 "re-advertising it now IS unchanged, and is refused"
3188 );
3189
3190 // Control drops the peer's disco key. The key the peer itself confirmed stays active.
3191 tracker.apply_peer_update(&control_full(None));
3192 assert_eq!(
3193 effective_key(&tracker, peer),
3194 Some(key),
3195 "a control key going away hands the active slot to the TSMP-learned key"
3196 );
3197 assert_eq!(tracker.peer_db.has(&key), Some(peer));
3198 }
3199
3200 /// A `PeersChangedPatch` is a control write like any other: one that says nothing about the
3201 /// disco key must leave an active TSMP key alone, and one that carries a new key is control
3202 /// catching up, so it wins.
3203 ///
3204 /// The patch path is the subtle one — it starts from the db node, which carries the *effective*
3205 /// key, so without re-deriving what control last said it would hand the TSMP key back as if
3206 /// control had sent it.
3207 #[tokio::test]
3208 async fn patch_without_a_disco_key_leaves_the_tsmp_key_active() {
3209 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3210 let advertised = DiscoPublicKey::from(ADVERTISED);
3211 assert!(tracker.learn_disco_key(peer, advertised));
3212
3213 // A reachability-only patch (the idle-peer-reconnect case) for the same node.
3214 let endpoint: std::net::SocketAddr = "203.0.113.9:41641".parse().unwrap();
3215 let mut patch = ts_control::PeerChange {
3216 id: 1,
3217 derp_region: None,
3218 cap: None,
3219 cap_map: None,
3220 underlay_addresses: Some(vec![endpoint]),
3221 node_key: None,
3222 key_signature: None,
3223 disco_key: None,
3224 node_key_expiry: None,
3225 online: None,
3226 last_seen: None,
3227 };
3228 tracker.apply_peer_patches(std::slice::from_ref(&patch));
3229 assert_eq!(
3230 effective_key(&tracker, peer),
3231 Some(advertised),
3232 "a patch that never mentions the disco key must not revert it to control's"
3233 );
3234 assert_eq!(
3235 tracker
3236 .peer_db
3237 .get(&peer)
3238 .expect("peer still present")
3239 .1
3240 .underlay_addresses,
3241 vec![endpoint],
3242 "and the patch it DID carry still applied"
3243 );
3244
3245 // Now control catches up through the patch channel.
3246 patch.disco_key = Some(DiscoPublicKey::from(CONTROL_CAUGHT_UP));
3247 tracker.apply_peer_patches(std::slice::from_ref(&patch));
3248 assert_eq!(
3249 effective_key(&tracker, peer),
3250 Some(DiscoPublicKey::from(CONTROL_CAUGHT_UP)),
3251 "a patch that does carry a disco key is control catching up, and wins"
3252 );
3253 }
3254
3255 /// The TSMP-learned key lives exactly as long as Go's endpoint does: it is dropped when the peer
3256 /// leaves the netmap, and it is not carried across a node-key rotation (Go builds the rotated
3257 /// peer a brand-new endpoint, with a brand-new `endpointDisco`).
3258 #[tokio::test]
3259 async fn tsmp_key_does_not_outlive_the_peer_or_its_node_key() {
3260 let (mut tracker, peer) = tracker_with_control_peer(Some(FROM_CONTROL));
3261 assert!(tracker.learn_disco_key(peer, DiscoPublicKey::from(ADVERTISED)));
3262
3263 // The peer leaves the netmap, then comes back on control's key.
3264 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![]));
3265 assert!(tracker.peer_db.peers().is_empty());
3266 assert!(
3267 tracker.endpoint_disco.is_empty(),
3268 "the departed peer's disco state goes with it"
3269 );
3270 tracker.apply_peer_update(&control_full(Some(FROM_CONTROL)));
3271 let readded = node_from_control(Some(FROM_CONTROL));
3272 let peer = tracker.peer_db.has(&readded.node_key).expect("re-added");
3273 assert_eq!(
3274 effective_key(&tracker, peer),
3275 Some(DiscoPublicKey::from(FROM_CONTROL)),
3276 "a peer that left and rejoined starts from control's key again"
3277 );
3278
3279 // Learn a key again, then rotate the node key underneath it.
3280 assert!(tracker.learn_disco_key(peer, DiscoPublicKey::from(READVERTISED)));
3281 let mut rotated = node_from_control(Some(FROM_CONTROL));
3282 rotated.node_key = [2u8; 32].into();
3283 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![rotated.clone()]));
3284 let peer = tracker
3285 .peer_db
3286 .has(&rotated.node_key)
3287 .expect("rotated peer");
3288 assert_eq!(
3289 effective_key(&tracker, peer),
3290 Some(DiscoPublicKey::from(FROM_CONTROL)),
3291 "a key learned under the old node key is not carried onto the new one"
3292 );
3293 assert_eq!(
3294 tracker.endpoint_disco.len(),
3295 1,
3296 "and the old node key's state is pruned"
3297 );
3298 }
3299}