ts_control/expiry.rs
1//! Node-key expiry enforcement — the port of Go's `expiryManager` (`ipn/ipnlocal/expiry.go`).
2//!
3//! [`Node::key_expired`](crate::Node::key_expired) only *reports* expiry. This module is what acts
4//! on it, and it does the three things upstream's `expiryManager` does:
5//!
6//! 1. [`ExpiryManager::flag_expired_peer`] — Go `flagExpiredPeers`: mark a peer whose `KeyExpiry`
7//! has passed as [`Node::expired`](crate::Node::expired), clear its endpoints and home DERP, and
8//! break its node key. The peer is deliberately **kept** in the netmap so callers can say *why*
9//! it is unreachable ("peer's node key has expired") instead of "no such peer".
10//! 2. [`ExpiryManager::next_peer_expiry`] — Go `nextPeerExpiry`: the soonest *future* expiry across
11//! the peers and the self node, so a caller can arm a timer and re-evaluate when a key actually
12//! expires rather than whenever the next netmap happens to arrive.
13//! 3. [`ExpiryManager::on_control_time`] — Go `onControlTime`: remember the delta between the local
14//! clock and `MapResponse.ControlTime`, and make every expiry comparison against the adjusted
15//! time. Without it every comparison silently trusts the local clock.
16//!
17//! The clock correction is bounded in both directions. A delta smaller than
18//! [`MIN_CLOCK_DELTA_SECS`] is stored as zero (control and this node agree closely enough), and a
19//! delta-adjusted "now" that
20//! lands before [`flag_expired_peers_epoch`] is refused outright — so a control server (or a
21//! Headscale) sending a wildly past `ControlTime` cannot expire the whole tailnet.
22//!
23//! Note that a peer with no expiry at all — `node_key_expiry` is `None`, Go's zero `KeyExpiry`,
24//! which is what a tagged node carries — is never expired, however far the clock moves.
25
26use alloc::{collections::BTreeMap, vec::Vec};
27use core::net::SocketAddr;
28
29use chrono::{DateTime, TimeDelta, Utc};
30use ts_derp::RegionId;
31use ts_keys::NodePublicKey;
32
33use crate::{Node, node::StableId};
34
35/// The hardcoded epoch a delta-adjusted "now" must not precede — Go `flagExpiredPeersEpoch`
36/// (`ipn/ipnlocal/expiry.go`), the approximate time upstream wrote that code (2023-01-10).
37///
38/// Extra defence in depth: if control sends a `ControlTime` far enough in the past that the
39/// adjusted clock lands before this, we refuse to reason about expiry at all rather than expire
40/// every peer in the tailnet.
41pub const FLAG_EXPIRED_PEERS_EPOCH_UNIX: i64 = 1_673_373_066;
42
43/// [`FLAG_EXPIRED_PEERS_EPOCH_UNIX`] as a timestamp.
44///
45/// # Panics
46/// Never: the constant is a valid Unix second.
47#[must_use]
48pub fn flag_expired_peers_epoch() -> DateTime<Utc> {
49 DateTime::from_timestamp(FLAG_EXPIRED_PEERS_EPOCH_UNIX, 0)
50 .expect("FLAG_EXPIRED_PEERS_EPOCH_UNIX is a representable timestamp")
51}
52
53/// Below this, the offset between local time and control's `ControlTime` is treated as zero — Go
54/// `minClockDelta` (`ipn/ipnlocal/expiry.go`), one minute.
55pub const MIN_CLOCK_DELTA_SECS: i64 = 60;
56
57/// How far past a computed next-expiry a caller should aim its timer, so the peer is unambiguously
58/// expired by the time the timer runs — Go's `nextExpiry.Sub(now) + 10*time.Second`
59/// (`ipn/ipnlocal/local.go`, `setControlClientStatusLocked`).
60pub const EXPIRY_TIMER_SLACK_SECS: i64 = 10;
61
62/// The floor a clock-skewed next-expiry is pushed to, so a timer built from it cannot fire
63/// immediately — Go's `localNow.Add(30 * time.Second)` in `nextPeerExpiry`.
64pub const CLOCK_SKEW_EXPIRY_FLOOR_SECS: i64 = 30;
65
66/// The error a peerAPI dial to an expired peer is refused with — Go
67/// `errors.New("peer's node key has expired")` (`ipn/ipnlocal/local.go`).
68pub const PEER_KEY_EXPIRED: &str = "peer's node key has expired";
69
70/// The outcome of an expiry pass over one peer — [`ExpiryManager::flag_expired_peer`].
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct FlaggedPeer {
73 /// The rewritten peer, for the caller to re-install in place of the one it passed in.
74 pub peer: Node,
75 /// Whether this is the *first* pass that found this peer in its new state, and so the one worth
76 /// logging.
77 ///
78 /// Go keeps a `previouslyExpired` set for exactly this: on a full netmap, control restates an
79 /// expired peer unflagged every time, so the rewrite has to be redone on every response while
80 /// the log line must appear once. `false` means "same episode, already reported".
81 pub first_transition: bool,
82}
83
84/// What flagging a peer expired destroys, kept so extending the peer's expiry can put it back.
85///
86/// Upstream needs no such record: `flagExpiredPeers` rewrites a netmap freshly derived from
87/// `controlclient`'s pristine peer store, so the next map response carries the real key, the real
88/// endpoints and the real home DERP again. Here the peer db is the only copy, so an expiry-only
89/// [`PeerChange`](crate::PeerChange) — which is exactly how control extends a key — would otherwise
90/// leave a recovered peer with a usable key and no way to route to it.
91#[derive(Debug, Clone, PartialEq, Eq)]
92struct PristinePeer {
93 /// The peer's real node key, before [`ts_keys::node_public_with_bad_old_prefix`] broke it.
94 node_key: NodePublicKey,
95 /// The peer's direct-path candidates (`tailcfg.Node.Endpoints`), before they were cleared.
96 underlay_addresses: Vec<SocketAddr>,
97 /// The peer's home DERP region, before it was cleared.
98 derp_region: Option<RegionId>,
99}
100
101/// Tracks expired peers and the local-to-control clock delta, and mutates peers to reflect expiry.
102///
103/// Go `ipnlocal.expiryManager`. One instance per node, owned by whatever holds the peer set (here:
104/// `ts_runtime`'s peer tracker), and driven from the netmap stream.
105#[derive(Debug, Default, Clone)]
106pub struct ExpiryManager {
107 /// Peers already flagged expired, so a transition is logged (and acted on) once rather than on
108 /// every netmap — Go `previouslyExpired`, which maps a stable id to a `bool`.
109 ///
110 /// This fork stores the peer state the flagging pass **destroys** ([`PristinePeer`]) instead of
111 /// a bare `true`. Upstream can afford a `bool` because `controlclient` keeps an unmutated peer
112 /// store and `flagExpiredPeers` only ever mutates a freshly-derived netmap; here the peer db
113 /// *is* the store, so the rewrite is in place and unrecoverable without a memo. Remembering it
114 /// under the same key, with the same lifetime (dropped the moment the peer is no longer
115 /// expired), restores upstream's behaviour when control extends a peer's expiry with a
116 /// field-level patch that does not restate those fields.
117 previously_expired: BTreeMap<StableId, PristinePeer>,
118
119 /// The offset to add to local time to get control's time — Go `clockDelta`, such that
120 /// `now() + clock_delta == MapResponse.ControlTime`. Zero until control sends a `ControlTime`
121 /// that differs by more than [`MIN_CLOCK_DELTA_SECS`].
122 clock_delta: TimeDelta,
123}
124
125impl ExpiryManager {
126 /// A manager with no known expired peers and no clock correction.
127 #[must_use]
128 pub fn new() -> Self {
129 Self::default()
130 }
131
132 /// Record a `MapResponse.ControlTime` — Go `onControlTime`.
133 ///
134 /// Stores the delta between control's clock and `local_now` when it exceeds
135 /// [`MIN_CLOCK_DELTA_SECS`] in either direction, and **clears** it back to zero when it does
136 /// not (control and this node agree; a previously-recorded skew has been corrected). Returns
137 /// the delta now in effect.
138 pub fn on_control_time(
139 &mut self,
140 control_time: DateTime<Utc>,
141 local_now: DateTime<Utc>,
142 ) -> TimeDelta {
143 let delta = control_time - local_now;
144 self.clock_delta = if delta.abs() > TimeDelta::seconds(MIN_CLOCK_DELTA_SECS) {
145 delta
146 } else {
147 TimeDelta::zero()
148 };
149 self.clock_delta
150 }
151
152 /// The stored local-to-control clock offset (Go `clockDelta`). Zero when control's clock is
153 /// within [`MIN_CLOCK_DELTA_SECS`] of this node's, or before any `ControlTime` has arrived.
154 #[must_use]
155 pub fn clock_delta(&self) -> TimeDelta {
156 self.clock_delta
157 }
158
159 /// Control's estimated current time — Go `LocalBackend.ControlNow`: `local_now` shifted by the
160 /// stored [`clock_delta`](Self::clock_delta). Every expiry comparison is made against this, not
161 /// against the raw local clock.
162 #[must_use]
163 pub fn control_now(&self, local_now: DateTime<Utc>) -> DateTime<Utc> {
164 local_now + self.clock_delta
165 }
166
167 /// Control's estimated current time, or `None` when it lands before
168 /// [`flag_expired_peers_epoch`] — the guard both `flagExpiredPeers` and `nextPeerExpiry` apply
169 /// before doing anything at all.
170 fn usable_control_now(&self, local_now: DateTime<Utc>) -> Option<DateTime<Utc>> {
171 let control_now = self.control_now(local_now);
172 (control_now >= flag_expired_peers_epoch()).then_some(control_now)
173 }
174
175 /// Apply the expiry pass to one peer — the body of Go's `flagExpiredPeers` loop.
176 ///
177 /// Returns the rewritten peer when it had to be changed, and `None` when nothing changed — so a
178 /// caller walking a large peer set clones only the handful of peers it has to re-install and
179 /// re-publish. [`FlaggedPeer::first_transition`] separates "this peer just expired" from "this
180 /// peer was already expired and control restated it unflagged", which is the difference between
181 /// a log line and a silent rewrite.
182 ///
183 /// On expiry the peer is **flagged, never dropped**: `expired` is set, its underlay endpoints
184 /// and home DERP are cleared (control does the same for expired nodes), and its node key is
185 /// broken with [`ts_keys::node_public_with_bad_old_prefix`] so nothing can handshake with it.
186 /// Keeping the row is the point — a caller that tries to reach the peer can then answer
187 /// [`PEER_KEY_EXPIRED`] instead of "no such peer".
188 ///
189 /// Skipped, returning `None`:
190 /// - a peer with no expiry at all (`node_key_expiry` is `None` — a tagged node; Go's zero
191 /// `KeyExpiry`), and a peer whose expiry is still in the future. Either way the peer is *not*
192 /// expired, so the pristine key, endpoints and home DERP a previous pass memoized are put
193 /// back (see the manager's own docs) and the memo dropped, exactly where Go does its
194 /// `delete(em.previouslyExpired, ...)`.
195 /// - a peer already marked [`Node::expired`](crate::Node::expired) — by control, or by an
196 /// earlier pass. Re-flagging would repeat the log and the invalidation on every netmap.
197 /// - every peer, when the delta-adjusted clock is before [`flag_expired_peers_epoch`].
198 pub fn flag_expired_peer(
199 &mut self,
200 peer: &Node,
201 local_now: DateTime<Utc>,
202 ) -> Option<FlaggedPeer> {
203 let control_now = self.usable_control_now(local_now)?;
204
205 // Not expired: nothing to flag. Drop any memo we hold for this peer — and if the peer we
206 // are holding is the one we rewrote, put back everything that rewrite destroyed, so a
207 // control-sent expiry extension recovers the peer's data path the way it does upstream.
208 if peer
209 .node_key_expiry
210 .is_none_or(|expiry| expiry > control_now)
211 {
212 let pristine = self.previously_expired.remove(&peer.stable_id)?;
213 if peer.node_key != ts_keys::node_public_with_bad_old_prefix(pristine.node_key) {
214 // The peer has since been restated by control with a key of its own; leave it be.
215 return None;
216 }
217 let mut peer = peer.clone();
218 peer.node_key = pristine.node_key;
219 peer.expired = false;
220 // The key alone is not enough to reach the peer: flagging also cleared its direct-path
221 // candidates and its home DERP, and an expiry-extending patch that restates neither
222 // would leave the recovered peer with no route at all until the next full netmap.
223 // Restore only what is still cleared — control restating either field in the same
224 // update is fresher than anything we memoized, and must win.
225 if peer.underlay_addresses.is_empty() {
226 peer.underlay_addresses = pristine.underlay_addresses;
227 }
228 if peer.derp_region.is_none() {
229 peer.derp_region = pristine.derp_region;
230 }
231 return Some(FlaggedPeer {
232 peer,
233 // The memo is gone, so this direction can only be reported once.
234 first_transition: true,
235 });
236 }
237
238 // Already expired (control said so, or we flagged it on an earlier pass). Re-running the
239 // mutation would re-log and re-invalidate on every netmap, so stop here.
240 if peer.expired {
241 return None;
242 }
243
244 // Go's `previouslyExpired` bookkeeping: remember the peer so the transition is reported
245 // once, not on every netmap that restates it. This fork remembers what the rewrite below
246 // destroys rather than a bare `true`, so it can be undone — see the field's docs.
247 let first_transition = self
248 .previously_expired
249 .insert(
250 peer.stable_id.clone(),
251 PristinePeer {
252 node_key: peer.node_key,
253 underlay_addresses: peer.underlay_addresses.clone(),
254 derp_region: peer.derp_region,
255 },
256 )
257 .is_none();
258
259 let mut peer = peer.clone();
260 peer.expired = true;
261 // Control clears these on an expired node; do it here too, as defence in depth against a
262 // control server handing us an expired node that still looks live.
263 peer.underlay_addresses.clear();
264 peer.derp_region = None;
265 // And break the key itself, in case something still tries to talk to the peer.
266 peer.node_key = ts_keys::node_public_with_bad_old_prefix(peer.node_key);
267
268 Some(FlaggedPeer {
269 peer,
270 first_transition,
271 })
272 }
273
274 /// The soonest *future* key expiry across `peers` and `self_node` — Go `nextPeerExpiry`.
275 ///
276 /// The result is in **local** time (the caller's clock), so `next - local_now` is directly the
277 /// delay to arm a timer for; see [`EXPIRY_TIMER_SLACK_SECS`] for the slack upstream adds on
278 /// top. `None` when nothing is due to expire, i.e. every peer is tagged (no expiry), already
279 /// expired, or already past its expiry without having been flagged.
280 ///
281 /// Two guards, both upstream's:
282 /// - the delta-adjusted clock must not precede [`flag_expired_peers_epoch`], else `None`.
283 /// - the answer is never before `local_now`. A local clock running *fast* relative to control
284 /// would otherwise produce a negative delay and a timer that fires immediately in a loop; in
285 /// that case the answer is floored at `local_now + `[`CLOCK_SKEW_EXPIRY_FLOOR_SECS`].
286 #[must_use]
287 pub fn next_peer_expiry<'a>(
288 &self,
289 peers: impl IntoIterator<Item = &'a Node>,
290 self_node: Option<&Node>,
291 local_now: DateTime<Utc>,
292 ) -> Option<DateTime<Utc>> {
293 let control_now = self.usable_control_now(local_now)?;
294
295 let mut next: Option<DateTime<Utc>> = None;
296 let mut consider = |node: &Node| {
297 let Some(expiry) = node.node_key_expiry else {
298 return; // tagged node: never expires
299 };
300 if node.expired || expiry < control_now {
301 // Already expired — flagged, or past its expiry for some other reason. Either way
302 // there is no future event here and we must not return a time in the past.
303 return;
304 }
305 if next.is_none_or(|soonest| expiry < soonest) {
306 next = Some(expiry);
307 }
308 };
309
310 for peer in peers {
311 consider(peer);
312 }
313 // Fire this timer for our own key expiry too, exactly as Go folds in `nm.SelfNode`.
314 if let Some(self_node) = self_node {
315 consider(self_node);
316 }
317
318 let next = next?;
319 if next < local_now {
320 // The local clock is ahead of control's: `next` is a real future control-time but a
321 // past local time. Push it out so a timer built from it does not fire immediately.
322 return Some(local_now + TimeDelta::seconds(CLOCK_SKEW_EXPIRY_FLOOR_SECS));
323 }
324 Some(next)
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use alloc::{string::ToString, vec, vec::Vec};
331
332 use chrono::{DateTime, TimeDelta, Utc};
333 use ts_keys::NodePublicKey;
334
335 use super::{
336 CLOCK_SKEW_EXPIRY_FLOOR_SECS, ExpiryManager, MIN_CLOCK_DELTA_SECS, flag_expired_peers_epoch,
337 };
338 use crate::{
339 Node,
340 node::{StableId, tests::test_node},
341 };
342
343 /// A well-after-the-epoch "now" so the epoch guard is never the thing under test.
344 fn now() -> DateTime<Utc> {
345 DateTime::from_timestamp(1_800_000_000, 0).unwrap()
346 }
347
348 fn peer(stable_id: &str, key: u8, expiry: Option<DateTime<Utc>>) -> Node {
349 let mut node = test_node();
350 node.stable_id = StableId(stable_id.to_string());
351 node.node_key = NodePublicKey::from([key; 32]);
352 node.node_key_expiry = expiry;
353 node.underlay_addresses = vec!["192.0.2.7:41641".parse().unwrap()];
354 node.derp_region = Some(ts_derp::RegionId(core::num::NonZeroU32::new(2).unwrap()));
355 node
356 }
357
358 #[test]
359 fn flags_a_peer_whose_expiry_has_passed() {
360 let mut em = ExpiryManager::new();
361 let original_key = NodePublicKey::from([7u8; 32]);
362 let p = peer("nOdE1", 7, Some(now() - TimeDelta::hours(1)));
363
364 let flagged = em.flag_expired_peer(&p, now()).expect("peer is flagged");
365 assert!(
366 flagged.first_transition,
367 "the first pass is the log-worthy one"
368 );
369 let p = flagged.peer;
370
371 assert!(p.expired);
372 assert!(p.underlay_addresses.is_empty());
373 assert_eq!(p.derp_region, None);
374 assert_eq!(
375 p.node_key,
376 ts_keys::node_public_with_bad_old_prefix(original_key)
377 );
378 // The peer is kept, not dropped: its identity is still resolvable so a caller can report
379 // *why* it is unreachable.
380 assert_eq!(p.stable_id, StableId("nOdE1".to_string()));
381 }
382
383 #[test]
384 fn does_not_reflag_an_already_expired_peer() {
385 let mut em = ExpiryManager::new();
386 let p = peer("nOdE1", 7, Some(now() - TimeDelta::hours(1)));
387
388 let flagged = em
389 .flag_expired_peer(&p, now())
390 .expect("peer is flagged")
391 .peer;
392
393 // Second pass over the peer we already rewrote: no change at all, so nothing is
394 // re-installed and, in particular, the already-broken key is never broken a second time.
395 assert_eq!(em.flag_expired_peer(&flagged, now()), None);
396
397 // Control restating the peer unflagged (every full netmap does) still has to be rewritten,
398 // but is no longer a transition, so it is not reported a second time.
399 let restated = em
400 .flag_expired_peer(&p, now())
401 .expect("a restated expired peer is rewritten again");
402 assert!(restated.peer.expired);
403 assert!(
404 !restated.first_transition,
405 "the log line appears once per episode, not once per netmap"
406 );
407 }
408
409 /// A control-sent `Expired` is honoured as-is: we must not stamp a bad prefix over the key
410 /// control gave us, and we must not log the transition a second time.
411 #[test]
412 fn control_sent_expired_is_left_alone() {
413 let mut em = ExpiryManager::new();
414 let mut p = peer("nOdE1", 7, Some(now() - TimeDelta::hours(1)));
415 p.expired = true;
416
417 assert_eq!(em.flag_expired_peer(&p, now()), None);
418 }
419
420 /// The negative case the bead calls out: a tagged node has no expiry at all and is never
421 /// flagged, however far the clock moves.
422 #[test]
423 fn a_peer_with_no_expiry_is_never_flagged() {
424 let mut em = ExpiryManager::new();
425 let p = peer("tAgGeD", 7, None);
426
427 assert_eq!(em.flag_expired_peer(&p, now()), None);
428 assert_eq!(
429 em.flag_expired_peer(&p, now() + TimeDelta::days(3650)),
430 None
431 );
432 assert!(!p.expired);
433 assert_eq!(em.next_peer_expiry([&p], None, now()), None);
434 }
435
436 #[test]
437 fn a_future_expiry_is_not_flagged() {
438 let mut em = ExpiryManager::new();
439 let p = peer("nOdE1", 7, Some(now() + TimeDelta::hours(1)));
440
441 assert_eq!(em.flag_expired_peer(&p, now()), None);
442 }
443
444 /// Control extending a peer's expiry (e.g. a `PeersChangedPatch` that restates only
445 /// `KeyExpiry`) must give the peer its real node key back, not leave it permanently broken.
446 #[test]
447 fn extending_the_expiry_restores_the_pristine_key() {
448 let mut em = ExpiryManager::new();
449 let original_key = NodePublicKey::from([7u8; 32]);
450 let p = peer("nOdE1", 7, Some(now() - TimeDelta::hours(1)));
451 let mut p = em
452 .flag_expired_peer(&p, now())
453 .expect("peer is flagged")
454 .peer;
455 assert_ne!(p.node_key, original_key);
456
457 p.node_key_expiry = Some(now() + TimeDelta::days(30));
458 let p = em
459 .flag_expired_peer(&p, now())
460 .expect("peer is un-flagged")
461 .peer;
462
463 assert!(!p.expired);
464 assert_eq!(p.node_key, original_key);
465 }
466
467 /// The key alone does not make a recovered peer reachable. Flagging clears the peer's
468 /// direct-path candidates and its home DERP, and an expiry-extending `PeerChange` restates
469 /// neither — so unless they are put back too, the peer comes back with a usable key and nowhere
470 /// to send. Upstream never has to: it rewrites a netmap derived from a pristine peer store.
471 #[test]
472 fn extending_the_expiry_restores_the_routing_fields() {
473 let mut em = ExpiryManager::new();
474 let live = peer("nOdE1", 7, Some(now() - TimeDelta::hours(1)));
475 let flagged = em
476 .flag_expired_peer(&live, now())
477 .expect("peer is flagged")
478 .peer;
479 assert!(flagged.underlay_addresses.is_empty());
480 assert_eq!(flagged.derp_region, None);
481
482 // Control extends the expiry and says nothing else — the shape of a `PeerChange` that
483 // carries only `KeyExpiry`.
484 let mut extended = flagged;
485 extended.node_key_expiry = Some(now() + TimeDelta::days(30));
486 let recovered = em
487 .flag_expired_peer(&extended, now())
488 .expect("peer is un-flagged")
489 .peer;
490
491 assert!(!recovered.expired);
492 assert_eq!(
493 recovered.underlay_addresses, live.underlay_addresses,
494 "the peer's direct candidates come back with it"
495 );
496 assert_eq!(
497 recovered.derp_region, live.derp_region,
498 "and so does its home DERP route"
499 );
500 }
501
502 /// The restore must not clobber. If the same update that extends the expiry also carries fresh
503 /// endpoints or a new home DERP, control's word is newer than anything we memoized at flagging
504 /// time and has to survive.
505 #[test]
506 fn a_restated_route_beats_the_memo() {
507 let mut em = ExpiryManager::new();
508 let live = peer("nOdE1", 7, Some(now() - TimeDelta::hours(1)));
509 let flagged = em
510 .flag_expired_peer(&live, now())
511 .expect("peer is flagged")
512 .peer;
513
514 let fresh_endpoint: core::net::SocketAddr = "198.51.100.4:41641".parse().unwrap();
515 let fresh_derp = ts_derp::RegionId(core::num::NonZeroU32::new(9).unwrap());
516 let mut extended = flagged;
517 extended.node_key_expiry = Some(now() + TimeDelta::days(30));
518 extended.underlay_addresses = vec![fresh_endpoint];
519 extended.derp_region = Some(fresh_derp);
520
521 let recovered = em
522 .flag_expired_peer(&extended, now())
523 .expect("peer is un-flagged")
524 .peer;
525
526 assert_eq!(recovered.underlay_addresses, vec![fresh_endpoint]);
527 assert_eq!(recovered.derp_region, Some(fresh_derp));
528 }
529
530 /// The boundary, pinned in both directions because the two functions in this module genuinely
531 /// disagree at it — and so do the upstream functions they port.
532 ///
533 /// `flagExpiredPeers` skips a peer whose `KeyExpiry().After(controlNow)`, so an expiry landing
534 /// exactly on control's clock **is** expired; `nextPeerExpiry` skips one whose
535 /// `KeyExpiry().Before(controlNow)`, so the same instant is *not* in the past there. The order
536 /// of operations makes the disagreement unobservable — the flagging pass runs first and marks
537 /// the peer, and `next_peer_expiry` then skips it on `expired` — but a peer that reached
538 /// `next_peer_expiry` unflagged would report an already-due event, exactly as upstream does.
539 #[test]
540 fn key_expiry_exactly_at_control_now_is_expired() {
541 let mut em = ExpiryManager::new();
542 let p = peer("nOdE1", 7, Some(now()));
543
544 let flagged = em
545 .flag_expired_peer(&p, now())
546 .expect("expiry == control's now is expired, matching Go's `After`");
547 assert!(flagged.peer.expired);
548
549 // The same instant is still a "future" event to `next_peer_expiry` (Go's `Before`), which
550 // only matters for a peer that was never flagged: a flagged one is skipped outright.
551 let em = ExpiryManager::new();
552 assert_eq!(em.next_peer_expiry([&p], None, now()), Some(now()));
553 assert_eq!(
554 em.next_peer_expiry([&flagged.peer], None, now()),
555 None,
556 "the flagged peer is skipped, so no timer is armed for an event already handled"
557 );
558 }
559
560 /// A delta-adjusted clock before the hardcoded epoch disables the whole subsystem: a control
561 /// server sending a wildly past `ControlTime` must not be able to expire the tailnet.
562 #[test]
563 fn a_vast_backwards_clock_jump_flags_nothing() {
564 let mut em = ExpiryManager::new();
565 // Control claims it is 2001; the delta drags the adjusted "now" before the epoch.
566 let control_time = DateTime::from_timestamp(1_000_000_000, 0).unwrap();
567 em.on_control_time(control_time, now());
568 assert!(em.control_now(now()) < flag_expired_peers_epoch());
569
570 let p = peer("nOdE1", 7, Some(now() - TimeDelta::hours(1)));
571 assert_eq!(em.flag_expired_peer(&p, now()), None);
572
573 let future = peer("nOdE2", 8, Some(now() + TimeDelta::hours(1)));
574 assert_eq!(em.next_peer_expiry([&future], None, now()), None);
575 }
576
577 #[test]
578 fn a_small_control_time_offset_is_ignored() {
579 let mut em = ExpiryManager::new();
580 let delta = em.on_control_time(now() + TimeDelta::seconds(MIN_CLOCK_DELTA_SECS), now());
581
582 assert_eq!(delta, TimeDelta::zero());
583 assert_eq!(em.control_now(now()), now());
584 }
585
586 #[test]
587 fn a_large_control_time_offset_shifts_every_comparison() {
588 let mut em = ExpiryManager::new();
589 let skew = TimeDelta::hours(2);
590 assert_eq!(em.on_control_time(now() + skew, now()), skew);
591
592 // Local time says the key expires in an hour; control's clock says it went an hour ago.
593 let p = peer("nOdE1", 7, Some(now() + TimeDelta::hours(1)));
594 let p = em
595 .flag_expired_peer(&p, now())
596 .expect("expired against control's clock");
597 assert!(p.peer.expired);
598 }
599
600 /// A later `ControlTime` that agrees with the local clock clears a previously stored skew.
601 #[test]
602 fn a_corrected_control_time_clears_the_delta() {
603 let mut em = ExpiryManager::new();
604 em.on_control_time(now() + TimeDelta::hours(2), now());
605 assert_ne!(em.clock_delta(), TimeDelta::zero());
606
607 em.on_control_time(now(), now());
608 assert_eq!(em.clock_delta(), TimeDelta::zero());
609 }
610
611 #[test]
612 fn next_peer_expiry_picks_the_soonest_future_expiry() {
613 let em = ExpiryManager::new();
614 let soon = now() + TimeDelta::minutes(5);
615 let peers: Vec<Node> = vec![
616 peer("a", 1, Some(now() + TimeDelta::hours(4))),
617 peer("b", 2, Some(soon)),
618 peer("c", 3, None),
619 ];
620
621 assert_eq!(em.next_peer_expiry(peers.iter(), None, now()), Some(soon));
622 }
623
624 #[test]
625 fn next_peer_expiry_skips_expired_and_past_peers() {
626 let em = ExpiryManager::new();
627 let mut flagged = peer("a", 1, Some(now() - TimeDelta::hours(1)));
628 flagged.expired = true;
629 // Past its expiry but never flagged — Go skips this too rather than returning a past time.
630 let stale = peer("b", 2, Some(now() - TimeDelta::minutes(1)));
631
632 assert_eq!(
633 em.next_peer_expiry([&flagged, &stale], None, now()),
634 None,
635 "no future event: the answer must not be a time in the past"
636 );
637 }
638
639 #[test]
640 fn next_peer_expiry_folds_in_the_self_node() {
641 let em = ExpiryManager::new();
642 let self_expiry = now() + TimeDelta::minutes(2);
643 let self_node = peer("self", 9, Some(self_expiry));
644 let p = peer("a", 1, Some(now() + TimeDelta::hours(4)));
645
646 assert_eq!(
647 em.next_peer_expiry([&p], Some(&self_node), now()),
648 Some(self_expiry)
649 );
650
651 // An already-passed self expiry is skipped, leaving the peer's.
652 let expired_self = peer("self", 9, Some(now() - TimeDelta::minutes(2)));
653 assert_eq!(
654 em.next_peer_expiry([&p], Some(&expired_self), now()),
655 p.node_key_expiry
656 );
657 }
658
659 /// The local clock running fast makes a genuinely-future control-time expiry look past. Go
660 /// floors the answer instead of returning a negative delay that spins the timer.
661 #[test]
662 fn next_peer_expiry_floors_a_clock_skewed_answer() {
663 let mut em = ExpiryManager::new();
664 // Control is two hours behind us, so an expiry 30 minutes out in control time is 90
665 // minutes in our past.
666 em.on_control_time(now() - TimeDelta::hours(2), now());
667 let p = peer("a", 1, Some(now() - TimeDelta::minutes(90)));
668
669 assert_eq!(
670 em.next_peer_expiry([&p], None, now()),
671 Some(now() + TimeDelta::seconds(CLOCK_SKEW_EXPIRY_FLOOR_SECS))
672 );
673 }
674}