Skip to main content

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}