Skip to main content

ts_runtime/
control_runner.rs

1use core::{
2    net::{Ipv4Addr, Ipv6Addr},
3    time::Duration,
4};
5use std::{collections::HashMap, sync::Arc, time::Instant};
6
7use futures::StreamExt;
8use kameo::{
9    actor::{ActorRef, Spawn},
10    message::{Context, StreamMessage},
11    prelude::Message,
12};
13use tokio::sync::watch;
14use ts_control::{
15    AsyncControlClient, Endpoint, EndpointType, Error as ControlError, IdTokenError, LogoutError,
16    Node, SetDnsError, SshPolicy, StateUpdate, TkaStatus, TkaSyncError, tka_disable,
17    tka_init_begin, tka_init_finish, tka_submit_signature,
18};
19use ts_magicsock::SelfEndpointType;
20
21use crate::{
22    derp_latency::{DerpLatencyMeasurement, DerpLatencyMeasurer},
23    direct::EndpointAdvertisement,
24};
25
26/// Actor responsible for maintaining the connection to control.
27///
28/// This actor is responsible for proxying the map response stream onto the message bus.
29pub struct ControlRunner {
30    client: AsyncControlClient,
31    params: Params,
32
33    self_node: watch::Sender<Option<Node>>,
34    /// Latest Tailscale SSH policy pushed by control, or `None` until control sends one. The SSH
35    /// server reads this to authorize incoming connections; absent policy means deny-all.
36    ssh_policy: watch::Sender<Option<SshPolicy>>,
37    /// Latest Tailnet Lock status pushed by control, or `None` until control sends one.
38    tka: watch::Sender<Option<TkaStatus>>,
39    /// The locally-synced Tailnet-Lock state (verified `Authority` + AUM store), or `None` until a
40    /// successful bootstrap+sync. Held here because `ControlRunner` owns the netmap stream that
41    /// triggers resync. Mutated only on the actor thread (the netmap handler spawns the sync RPC and
42    /// the result returns via the [`TkaSynced`] self-message).
43    tka_synced: Option<crate::tka_sync::SyncedTka>,
44    /// The Tailnet-Lock chain persisted beside the cached netmap for the cold-start replay, and the
45    /// single ordered path that writes or removes it. See [`PersistedTkaChain`].
46    tka_chain: PersistedTkaChain,
47    /// The verified TKA [`Authority`](ts_tka::Authority) the peer tracker **enforces** (Go
48    /// `tkaFilterNetmapLocked`). `None` until the first successful sync, and reset to `None` when the
49    /// lock is disabled. This is the SOLE delivery channel to the peer tracker (which holds the
50    /// matching `Receiver` and reads it on every peer upsert): a `watch` cell, not a bus message, so
51    /// the latest value is always readable, never dropped under load, and writes are strictly ordered
52    /// by this actor — a disable (`None`) can never be reordered behind or dropped before a stale
53    /// `Some`. Written only from [`apply_tka_synced`] (enable) and [`maybe_sync_tka`] (disable), both
54    /// on the actor thread. The published `Authority` has always passed `VerifiedAumChain::verify`.
55    tka_authority: watch::Sender<Option<Arc<ts_tka::Authority>>>,
56    /// In-flight guard: `true` while a sync RPC task is running, so a burst of netmap updates does
57    /// not spawn overlapping syncs (Go serializes sync under `b.mu`).
58    tka_syncing: bool,
59    /// Monotonic generation stamped when a disable (or a fresh sync) supersedes any in-flight sync.
60    /// `maybe_sync_tka` bumps this on a disable transition and captures it into each spawned sync;
61    /// [`apply_tka_synced`] discards a sync result whose captured generation is stale, so a lock
62    /// disabled *while a sync was in flight* is never re-enabled by that sync's late `Ok(Some)`
63    /// (the in-flight window the `tka_synced.is_some()` disable guard alone does not cover).
64    tka_generation: u64,
65    /// Latest cert-domain list from control's netmap DNS config (Go `nm.DNS.CertDomains`), or empty
66    /// until control sends a DNS config carrying one. The facade reads this for `Device::cert_domains`.
67    cert_domains: watch::Sender<Vec<String>>,
68    /// Latest full DNS config from control's netmap (Go `netmap.NetworkMap.DNS`), or `None` until
69    /// control sends one. The facade reads this for `Device::dns_config` (the daemon's
70    /// `tnet dns status`). A superset of [`cert_domains`](Self::cert_domains), which is kept as its
71    /// own cell for the narrower TLS-cert use.
72    dns_config: watch::Sender<Option<ts_control::DnsConfig>>,
73    /// Latest interactive-login / consent URL control asked this node to open
74    /// (`MapResponse.PopBrowserURL`), or `None` until control sends one. The facade reads this for
75    /// `Device::pop_browser_url` (a daemon driving a non-authkey login surfaces it to the user), and
76    /// [`Runtime::watch_ipn_bus`](crate::Runtime::watch_ipn_bus) subscribes to it for the bus's
77    /// `browse_to_url` running-node events.
78    ///
79    /// **Sticky, not per-update** (Go `controlclient` `sess.lastPopBrowserURL`): control sends
80    /// `MapResponse.PopBrowserURL` empty on nearly every netmap tick, so this cell is updated ONLY on
81    /// a non-empty URL that differs from its current value (`sticky_update_pop_browser_url`, via
82    /// `send_if_modified` — the cell's own value is the "last URL seen", so no separate mirror is
83    /// needed). It is never reset to `None` by an empty update — matching Go's `direct.go` guard
84    /// `u != "" && u != sess.lastPopBrowserURL`. Updating on every tick would thrash the cell to
85    /// `None` and coalesce the URL away for a `watch` subscriber.
86    pop_browser_url: watch::Sender<Option<url::Url>>,
87    /// Latest network-conditions report (preferred DERP region + per-region latencies), updated each
88    /// time the DERP-latency measurer reports in. The facade reads this for `Device::netcheck` (the
89    /// daemon's `tnet netcheck`). Empty until the first measurement.
90    netcheck: watch::Sender<crate::status::NetcheckReport>,
91    /// The DERP home region currently selected, with the latency measured for it at selection time.
92    /// `None` until the first home region is chosen. Used to apply selection **hysteresis** (Go
93    /// `netcheck.addReportHistoryAndSetPreferredDERP`): the home region is only switched when a new
94    /// region is *meaningfully* lower-latency than the current one, so jitter between near-equal
95    /// regions does not flap the home relay (which would cause repeated reconnects + brief loss).
96    home_region: Option<(ts_derp::RegionId, core::time::Duration)>,
97    /// Rolling history of per-cycle DERP-latency reports within the last [`DERP_HISTORY_MAX_AGE`]
98    /// (Go `netcheck` `maxAge`), each stamped with its arrival `Instant`. This is Go's one
99    /// `netcheck.Client.prev` history and, like it, has two readers:
100    ///
101    /// 1. the `bestRecent` smoothing (Go `addReportHistoryAndSetPreferredDERP`) — the new home
102    ///    candidate is chosen by each region's **minimum** latency over this window, not its raw
103    ///    current sample, so a best region whose latency oscillates across the switch boundary does
104    ///    not flap the home relay; and
105    /// 2. [`ControlRunner::recent_region_latency`] (Go `RecentRegionLatency`) — the per-region
106    ///    latencies the exit-node suggestion ranks candidates on, which need the union of several
107    ///    *partial* measurements to cover every region at all.
108    ///
109    /// Aged entries are evicted on each measurement; the buffer is therefore bounded by the netcheck
110    /// cadence × the window.
111    derp_report_history: Vec<(Instant, Arc<Vec<ts_netcheck::RegionResult>>)>,
112    /// Consecutive automatic-reauth attempts that have NOT yet recovered the node to a good (non-
113    /// expired) self-node. The circuit breaker for [`expiry_action`]'s `Reauthenticate` path: a
114    /// one-shot / already-consumed auth key cannot re-register, so without a bound an expired node
115    /// would sit in [`DeviceState::Reauthenticating`] indefinitely (the rotated re-register keeps
116    /// failing or control keeps returning a still-expired self-node).
117    ///
118    /// Incremented once per expired self-node seen while *already* `Reauthenticating` (each such
119    /// arrival is evidence the prior reauth did not recover); reset to `0` whenever a good
120    /// (non-expired) self-node arrives (the node recovered) — see the [`StreamMessage::Next`] handler.
121    /// At [`MAX_REAUTH_ATTEMPTS`] the runner stops re-arming reauth and flips the cell to the terminal
122    /// [`DeviceState::Expired`], giving the cell a stable terminal state (a genuinely good self-node
123    /// can still recover it later). The one-shot `Command::Reauth` is fired ONLY on the transition
124    /// INTO `Reauthenticating`, never re-fired while already reauthenticating, so the node key is
125    /// rotated at most once per episode (a second rotation would lose the original `OldNodeKey`
126    /// anchor control needs to link the rotation).
127    reauth_attempts: u32,
128    /// Background task that bridges the control client's mid-session re-auth URL cell onto
129    /// [`Self::params`]'s device-state cell (sets [`DeviceState::NeedsLogin`] when control returns
130    /// `MachineNotAuthorized` on a live re-register — see [`bridge_reauth_url_to_state`]). Aborted on
131    /// [`Drop`] so it cannot outlive the actor (the [`DataplaneActor`](crate::dataplane) pattern).
132    reauth_bridge: tokio::task::JoinHandle<()>,
133}
134
135/// The number of consecutive failed automatic-reauth attempts after which the runner gives up
136/// re-arming reauth and flips the device to the terminal [`DeviceState::Expired`] (the circuit
137/// breaker from the design's deferred-question Q1). A one-shot auth key was consumed by the first
138/// registration, so an auto re-register with it cannot succeed; this bounds that case to today's
139/// terminal behavior instead of an indefinite `Reauthenticating` spell. The first arrival fires the
140/// reauth; each subsequent expired self-node while still reauthenticating counts toward this cap.
141const MAX_REAUTH_ATTEMPTS: u32 = 3;
142
143impl Drop for ControlRunner {
144    fn drop(&mut self) {
145        // Stop the re-auth bridge so it does not outlive the actor (mirrors `DataplaneActor`).
146        self.reauth_bridge.abort();
147    }
148}
149
150/// Control runner args.
151pub struct Params {
152    /// Control config.
153    pub(crate) config: ts_control::Config,
154
155    /// Auth key (if needed).
156    pub(crate) auth_key: Option<String>,
157
158    /// The [`crate::Env`] for this actor.
159    pub(crate) env: crate::Env,
160
161    /// Sender for the device connection-state cell. Created in [`Runtime::spawn`](crate::Runtime)
162    /// so it outlives the actor's `on_start` (which may publish [`DeviceState::Failed`] and then
163    /// return `Err`, before `Self` exists). The runtime keeps the matching `Receiver` for
164    /// [`watch_state`](crate::Runtime::watch_state) / [`wait_until_running`](crate::Runtime::wait_until_running).
165    pub(crate) state_tx: watch::Sender<crate::DeviceState>,
166
167    /// Sender for the TKA enforcement-authority cell the peer tracker reads (Go
168    /// `tkaFilterNetmapLocked`). Created in [`Runtime::spawn`](crate::Runtime) and threaded into BOTH
169    /// the peer tracker (the `Receiver`) and this runner (the `Sender`), so the runner is the sole
170    /// writer and the tracker reads the latest verified `Authority` on demand. `None` = no lock /
171    /// disabled (admit all).
172    pub(crate) tka_authority: watch::Sender<Option<Arc<ts_tka::Authority>>>,
173
174    /// Sender for the selected DERP home region (the **smoothed** `bestRecent` + hysteresis choice,
175    /// Go `report.PreferredDERP`). Created in [`Runtime::spawn`](crate::Runtime); the runner is the
176    /// sole writer and [`Multiderp`](crate::multiderp) holds the `Receiver`, so the local DERP relay
177    /// follows the SAME home the runner advertises to control — not the raw per-cycle latency
178    /// minimum (which would flap on jitter and disagree with the advertised home). `None` until the
179    /// first home is chosen.
180    pub(crate) home_region: watch::Sender<Option<ts_derp::RegionId>>,
181}
182
183#[doc(hidden)]
184#[derive(Debug, thiserror::Error)]
185pub enum ControlRunnerError {
186    #[error(transparent)]
187    Control(#[from] ControlError),
188
189    #[error(transparent)]
190    Crate(#[from] crate::Error),
191}
192
193impl kameo::Actor for ControlRunner {
194    type Args = Params;
195    type Error = ControlRunnerError;
196
197    async fn on_start(params: Params, slf: ActorRef<Self>) -> Result<Self, Self::Error> {
198        // Cold start: replay the netmap this node cached the last time control granted it
199        // `cache-network-maps` (Go `nodecap.CacheNetworkMaps`, capability version 135 — upstream
200        // does the same from `ipnlocal.Start`, feeding the cached map through
201        // `setControlClientStatusLocked` before it builds its control client). Publishing it here,
202        // ahead of registration, is the whole point: the peer tracker, dataplane and packet filter
203        // get the last-known peers, DERP map and rules while this node is still doing its
204        // register/poll round trips, so peer connectivity can start coming up before control has
205        // said a word. Control's own first netmap lands on the same bus moments later and replaces
206        // it, and a netmap that no longer carries the attribute deletes the cache (see
207        // `NetmapCache::observe`).
208        //
209        // Nothing here can fail the start-up: no cache configured, nothing cached, or an
210        // undecodable cache all mean "carry on without one".
211        replay_cached_netmap(&params).await;
212
213        // The interactive AuthURL, captured on the first unauthorized reply and reused as the
214        // `followup` on every subsequent poll so control long-polls ONE stable URL (rather than
215        // minting a fresh, racing URL each retry) until the user visits it.
216        let mut login_url: Option<url::Url> = None;
217        loop {
218            match AsyncControlClient::check_auth(
219                &params.config,
220                &params.env.keys,
221                params.auth_key.as_deref(),
222                login_url.as_ref(),
223            )
224            .await
225            {
226                Ok(()) => break,
227                Err(ControlError::MachineNotAuthorized(u)) => {
228                    // Capture the FIRST url and keep showing/following-up on it, so the link the
229                    // user opened stays valid instead of being superseded by the next poll.
230                    let url = login_url.get_or_insert(u).clone();
231                    tracing::info!(auth_url = %url, "please authorize this machine or pass an auth key");
232                    // Publish `NeedsLogin(url)` only when it actually changes the cell. With `followup`
233                    // set, the SAME URL is re-affirmed on every long-poll timeout; a bare `send_replace`
234                    // re-notifies `state_rx` each cycle, so the bus re-emits `browse_to_url` for a link
235                    // the user already has — reopening it repeatedly. `send_if_modified` dedups the
236                    // no-op, mirroring `bridge_reauth_url_to_state` (the mid-session re-auth path).
237                    let next = crate::DeviceState::NeedsLogin(url);
238                    params.state_tx.send_if_modified(|current| {
239                        if *current == next {
240                            false
241                        } else {
242                            *current = next.clone();
243                            true
244                        }
245                    });
246                    // With followup set, check_auth long-polls until the URL is visited; this short
247                    // sleep only applies if control times the poll out, and we re-followup the SAME url.
248                    tokio::time::sleep(Duration::from_secs(2)).await;
249                }
250                Err(ControlError::NeedsMachineAuth) => {
251                    // The node is registered with a valid key but awaiting ADMIN APPROVAL on an
252                    // approval-gated tailnet, and control offered NO interactive URL. This is
253                    // TRANSIENT (Go's `NeedsMachineAuth`): poll registration until an admin approves,
254                    // then `check_auth` returns `Ok(())` → the loop breaks and the node comes up with
255                    // no re-registration. Publishing the (no-URL) `NeedsMachineAuth` state — NOT a
256                    // terminal `Failed` and NOT `NeedsLogin` (there is no URL to open) — lets a
257                    // watcher / `wait_until_running` see "awaiting approval" instead of an opaque
258                    // timeout. Same 5s poll cadence as the `MachineNotAuthorized(url)` arm.
259                    tracing::info!(
260                        "machine awaiting admin approval to join the tailnet; polling until approved"
261                    );
262                    params
263                        .state_tx
264                        .send_replace(crate::DeviceState::NeedsMachineAuth);
265                    tokio::time::sleep(Duration::from_secs(5)).await;
266                }
267                Err(ControlError::RateLimited(retry_after)) => {
268                    // Control asked us to slow down (HTTP 429). Wait exactly the server-requested
269                    // cooldown and retry — this is transient, NOT a terminal `Failed`, so we must
270                    // not stop the runner (mirrors Go's `authRoutine` sleeping `rle.retryAfter`).
271                    tracing::warn!(
272                        ?retry_after,
273                        "control rate-limited registration; waiting before retry"
274                    );
275                    tokio::time::sleep(retry_after).await;
276                }
277                Err(e) => {
278                    // Followup auth path gone: while long-polling a STABLE AuthURL, control returns
279                    // an HTTP registration error (410 "auth path not found") once the path is either
280                    // VISITED + approved (consumed) or expired. This is NOT terminal — drop the
281                    // followup and re-register ONCE: an approved node key comes back `MachineAuthorized`
282                    // (`Ok(())` → login completes), an expired one comes back with a fresh AuthURL.
283                    // Without this, the user's own approval (which consumes the path → 410) kills the
284                    // runner. Bounded: on re-register `login_url` is None, so a repeat HTTP error falls
285                    // through to the terminal handling below (no infinite loop).
286                    if login_url.is_some()
287                        && matches!(
288                            e,
289                            ControlError::Internal(
290                                ts_control::InternalErrorKind::Http,
291                                ts_control::Operation::Registration,
292                            )
293                        )
294                    {
295                        tracing::info!(
296                            "followup auth path gone (approved or expired); re-registering"
297                        );
298                        login_url = None;
299                        tokio::time::sleep(Duration::from_secs(1)).await;
300                        continue;
301                    }
302                    // A hard registration failure (bad/expired/unknown auth key, etc.). Log the
303                    // specific reason control gave AND publish it as a typed `Failed` state so
304                    // `Device::wait_until_running` returns the actionable reason (tsr-kqj) instead
305                    // of the opaque `Internal(Actor)` the caller would otherwise see once the
306                    // stopped actor is next asked. Publishing before `return Err` is why the state
307                    // sender lives on `Runtime`, not on `Self` (which never gets constructed here).
308                    let reason = crate::RegistrationError::from(&e);
309                    tracing::error!(error = %e, "registration failed; control runner stopping");
310                    params
311                        .state_tx
312                        .send_replace(crate::DeviceState::Failed(reason));
313                    return Err(e.into());
314                }
315            }
316        }
317        // check_auth succeeded, but the node is not "up" until the netmap stream is actually
318        // attached below. Publish `Running` only AFTER `attach_stream` so `wait_until_running` never
319        // resolves `Ok` for a device whose stream connect failed (which would leave a stopped actor
320        // behind). If the connect/subscribe steps fail, publish a transient `Failed` first so the
321        // waiter sees an actionable reason instead of the opaque post-mortem `Internal(Actor)`.
322        // The control client's live map-poll loop publishes a mid-session re-auth URL here (set when
323        // a re-register returns `MachineNotAuthorized` because the node key expired/was revoked). The
324        // runtime owns the receiver; `connect` takes the sender. Created before `connect` so the
325        // sender is in place for the very first poll, and so the receiver outlives `bring_up`.
326        let (auth_url_tx, auth_url_rx) = watch::channel::<Option<url::Url>>(None);
327
328        // `connect` issues its own `machine/register` POST (a second one after `check_auth`'s), so
329        // it too can hit a 429. Wrap it in the same honor-`Retry-After` retry as the `check_auth`
330        // loop above: a rate-limit is transient — sleeping the server-requested cooldown and
331        // retrying must NOT stop the runtime (a 429 here previously fell into the `Err(e)` arm →
332        // `Failed` → actor stop). `connect` consumes a `watch::Sender` by value (and drops it on a
333        // failed register, before it would be moved into the live-poll task), so we keep the original
334        // `auth_url_tx` alive here across all attempts and hand `connect` a CLONE each time. That
335        // keeps the runtime's `auth_url_rx` (the `reauth_bridge` receiver) paired with a live sender:
336        // recreating a fresh sender per attempt instead would orphan the bridge the moment the
337        // original dropped, silently killing mid-session re-auth-URL delivery.
338        let client = loop {
339            let bring_up = async {
340                let (client, stream) = AsyncControlClient::connect(
341                    &params.config,
342                    &params.env.keys,
343                    params.auth_key.as_deref(),
344                    auth_url_tx.clone(),
345                )
346                .await?;
347
348                DerpLatencyMeasurer::spawn_link(&slf, params.env.clone()).await;
349
350                params.env.subscribe::<DerpLatencyMeasurement>(&slf).await?;
351                params.env.subscribe::<EndpointAdvertisement>(&slf).await?;
352                slf.attach_stream(stream.boxed(), (), ());
353                Ok::<_, ControlRunnerError>(client)
354            };
355
356            match bring_up.await {
357                Ok(client) => break client,
358                Err(ControlRunnerError::Control(ControlError::RateLimited(retry_after))) => {
359                    tracing::warn!(
360                        ?retry_after,
361                        "control rate-limited the session bring-up; waiting before retry"
362                    );
363                    tokio::time::sleep(retry_after).await;
364                }
365                Err(ControlRunnerError::Control(ControlError::NeedsMachineAuth)) => {
366                    // `connect` issues its OWN `machine/register` POST (a second one after
367                    // `check_auth`'s), so it too can come back "awaiting admin approval, no URL". In
368                    // the normal flow the `check_auth` loop above already gated this (it only breaks
369                    // once registration returns `Ok`, and approval is monotonic), so this arm is the
370                    // defensive twin for a node de-authorized in the race window between the two POSTs:
371                    // treat it as TRANSIENT exactly like the `check_auth` arm — publish the (no-URL)
372                    // `NeedsMachineAuth` state, poll the same 5s, and retry the bring-up — rather than
373                    // collapsing into the terminal `Failed` arm below (which would permanently stop
374                    // the runner on a recoverable await-approval). Mirrors Go's `NeedsMachineAuth`.
375                    tracing::info!(
376                        "machine awaiting admin approval during session bring-up; polling until \
377                         approved"
378                    );
379                    params
380                        .state_tx
381                        .send_replace(crate::DeviceState::NeedsMachineAuth);
382                    tokio::time::sleep(Duration::from_secs(5)).await;
383                }
384                Err(e) => {
385                    tracing::error!(error = %e, "bringing up the control session failed");
386                    // The control session never came up; surface it as a transient registration
387                    // failure (a retry / fresh `Device::new` may succeed) rather than leaving the
388                    // state stuck at `Connecting`.
389                    params.state_tx.send_replace(crate::DeviceState::Failed(
390                        crate::RegistrationError::NetworkUnreachable,
391                    ));
392                    return Err(e);
393                }
394            }
395        };
396
397        // The netmap stream is attached: the node is up. The stream `Next` handler keeps this
398        // current (and flips to `Expired` if the self-node's key lapses).
399        params.state_tx.send_replace(crate::DeviceState::Running);
400
401        // Bridge the control client's mid-session re-auth URL cell onto the device-state cell: a
402        // `Some(url)` (control returned `MachineNotAuthorized` on a live re-register) becomes
403        // `DeviceState::NeedsLogin(url)` so the IPN bus surfaces `browse_to_url` and the embedder can
404        // prompt the user — the live-session analogue of the initial `check_auth` loop above. The
405        // recovery to `Running` is the netmap self-node handler's job (next good self-node), so this
406        // bridge only forwards `Some`. The task ends when the sender drops (the client's `run` task
407        // ended) and is aborted on actor `Drop`, so it cannot leak past the actor.
408        let reauth_bridge = {
409            let state_tx = params.state_tx.clone();
410            let mut auth_url_rx = auth_url_rx;
411            tokio::spawn(async move {
412                while auth_url_rx.changed().await.is_ok() {
413                    let url = auth_url_rx.borrow_and_update().clone();
414                    bridge_reauth_url_to_state(&state_tx, url.as_ref());
415                }
416            })
417        };
418
419        // Clone the TKA authority publisher before `params` moves into `Self` below. The matching
420        // `Receiver` lives on the peer tracker; this sender is the sole writer (enforce on sync,
421        // clear on disable).
422        let tka_authority = params.tka_authority.clone();
423
424        // Same reason: the owner of the persisted Tailnet-Lock chain is built from the config before
425        // `params` moves. It starts out assuming a chain may already be on disk — an earlier process
426        // of this node can have left one there, and this one has synced nothing yet.
427        let tka_chain = PersistedTkaChain::new(params.config.netmap_cache_dir.as_ref());
428
429        Ok(Self {
430            client,
431            params,
432            self_node: Default::default(),
433            ssh_policy: Default::default(),
434            tka: Default::default(),
435            tka_synced: None,
436            tka_chain,
437            tka_authority,
438            tka_syncing: false,
439            tka_generation: 0,
440            cert_domains: Default::default(),
441            dns_config: Default::default(),
442            pop_browser_url: Default::default(),
443            netcheck: Default::default(),
444            home_region: None,
445            derp_report_history: Vec::new(),
446            reauth_attempts: 0,
447            reauth_bridge,
448        })
449    }
450}
451
452impl ControlRunner {
453    /// Decide whether the latest netmap's Tailnet-Lock status warrants a (re)sync and, if so, spawn
454    /// the bootstrap+sync RPC off the actor thread (so the netmap stream never blocks on a control
455    /// round-trip). The result returns via the [`TkaSynced`] self-message.
456    ///
457    /// Triggers when control reports TKA enabled (`is_enabled`) AND we are not already syncing AND
458    /// either we hold no `Authority` yet (→ bootstrap) or control's head differs from ours (→ catch
459    /// up). When TKA is disabled, clears any synced state (the lock was turned off). Mirrors Go's
460    /// `tkaSyncIfNeeded`: a no-op when our head already matches.
461    async fn maybe_sync_tka(&mut self, tka: &TkaStatus, self_ref: ActorRef<Self>) {
462        if !tka.is_enabled() {
463            // Lock disabled (or never enabled): clear enforcement by writing `None` to the authority
464            // cell the peer tracker reads — synchronously, so it can never be reordered behind or
465            // dropped before a stale `Some` (the failure a best-effort broadcast had). Always bump the
466            // generation so ANY sync currently in flight is invalidated: without this, a disable that
467            // races an in-flight sync (whose `take()` already cleared `tka_synced`) would be a no-op
468            // here, and the sync's late `Ok(Some)` would silently re-enable a lock control just turned
469            // off (the in-flight window the `tka_synced.is_some()` guard alone misses). Cheap and
470            // idempotent: clearing an already-`None` cell and bumping the generation are harmless.
471            self.tka_generation = self.tka_generation.wrapping_add(1);
472            if self.tka_synced.is_some() {
473                tracing::info!("TKA lock disabled; clearing enforcement (admitting all peers)");
474                self.tka_synced = None;
475            }
476            // Outside that guard, deliberately: the chain on disk is not this process's to have
477            // synced. A cold start holds `tka_synced == None` while an earlier process's chain is
478            // still in the cache directory, and that chain is exactly the one the *next* cold start
479            // would find. So a lock control reports off clears it whether or not we ever enforced it.
480            // Dropping it is not what keeps the next cold start honest (the replay only vouches when
481            // the cached frame itself says the lock was on, and only with a chain at that frame's
482            // head) — it is so a disabled lock leaves nothing of itself on disk.
483            self.tka_chain.discard().await;
484            self.tka_authority.send_replace(None);
485            return;
486        }
487        if self.tka_syncing {
488            return; // a sync is already in flight; the next netmap will re-trigger if still stale
489        }
490        // Up-to-date check: if we already have an Authority whose head matches control's, nothing to
491        // do. A malformed control head is treated as "different" (we'll attempt a sync, which
492        // fail-closes harmlessly).
493        if let Some(synced) = &self.tka_synced
494            && let Some(control_head) = ts_tka::AumHash::from_base32(&tka.head)
495            && synced.authority.head_matches(&control_head)
496        {
497            return;
498        }
499
500        // Spawn the sync. Move the current synced state out (the driver takes it by value and returns
501        // the advanced state); `tka_synced` stays `None` until the result lands, guarded by
502        // `tka_syncing` so we don't spawn a second concurrent sync. Capture the current generation so
503        // `apply_tka_synced` can discard this result if a disable bumped the generation while the sync
504        // was in flight (H1: don't re-enable a lock that was disabled mid-sync).
505        self.tka_syncing = true;
506        let generation = self.tka_generation;
507        let current = self.tka_synced.take();
508        let config = self.params.config.clone();
509        let keys = self.params.env.keys.clone();
510        tokio::spawn(async move {
511            let result = crate::tka_sync::sync_tka(&config, &keys, current).await;
512            // Hand the outcome back to the actor thread to apply (mutating actor state off-thread is
513            // not allowed). A send failure just means the actor is gone — nothing to do.
514            if let Err(e) = self_ref.tell(TkaSynced { result, generation }).await {
515                tracing::debug!(error = ?e, "TKA sync result not delivered (actor gone)");
516            }
517        });
518    }
519
520    /// Apply the outcome of a spawned [`maybe_sync_tka`] task on the actor thread: store the advanced
521    /// state + publish the `Authority` to the peer tracker's enforcement cell (or, on inert/failed
522    /// sync, leave peers unaffected). Always clears the in-flight guard.
523    ///
524    /// `generation` is the value captured when the sync was spawned. If it no longer matches
525    /// `self.tka_generation`, the lock was disabled (or re-synced) while this sync was in flight, so
526    /// the result is discarded — never re-enabling an authority control has since turned off.
527    async fn apply_tka_synced(
528        &mut self,
529        result: Result<Option<crate::tka_sync::SyncedTka>, crate::tka_sync::TkaSyncDriverError>,
530        generation: u64,
531    ) {
532        self.tka_syncing = false;
533
534        // H1 guard: a disable (or a superseding sync) bumped the generation while this sync ran. Drop
535        // the stale result — `maybe_sync_tka`'s disable branch already cleared enforcement to `None`,
536        // and re-applying this `Some` would re-enforce a lock that is no longer active.
537        if generation != self.tka_generation {
538            tracing::info!(
539                "TKA sync result superseded (lock disabled or re-synced mid-flight); discarding"
540            );
541            return;
542        }
543
544        match result {
545            Ok(Some(synced)) => {
546                tracing::info!(
547                    head = %synced.authority.head().to_base32(),
548                    "TKA sync succeeded; enforcing verified Authority (Go tkaFilterNetmapLocked)"
549                );
550                // Deliver the verified Authority to the peer tracker's enforcement cell. The tracker
551                // reads it on every peer upsert and drops unauthorized peers. `Some(..)` = enforce; a
552                // `None` is written on disable. `watch` is the sole channel (last-write-wins, never
553                // dropped, ordered by this actor) — no bus, no re-publish-for-replay needed.
554                self.tka_authority
555                    .send_replace(Some(synced.authority.clone()));
556
557                // Observability (Go `tkaFilterNetmapLocked`'s self check → `LockedOut` health
558                // warning): verify SELF's own node-key signature against the freshly-synced
559                // Authority and warn if self is NOT authorized. We never FILTER self (self never
560                // enters the peer db, so enforcement can't lock us out of our own netmap), but Go
561                // raises an operator-facing warning here because a self that the lock does not
562                // authorize means this node's key-signature is missing/invalid for the current lock
563                // — it will be unable to prove itself to locked peers. This fork has no health
564                // subsystem, so the signal is a `tracing::warn!` (its observability channel).
565                //
566                // `self_node` is a sticky cell set on every netmap carrying a self-node; if a sync
567                // somehow lands before the first self-node ever arrived it is `None`, so we skip the
568                // advisory this cycle and re-evaluate on the next sync — fine for observability-only.
569                // The `borrow()` ref is scoped to this `if let` and dropped before the `&mut self`
570                // write below.
571                if let Some(self_node) = self.self_node.borrow().as_ref() {
572                    log_self_lockout(self_node, &synced.authority);
573                }
574
575                // Persist the verified chain beside the cached netmap so the *next* cold start can
576                // run this same filter over the cached peers before control answers — Go's cold
577                // start filters its cached map against the authority it re-opens from disk, and this
578                // is the disk it re-opens (see `replay_cached_netmap`).
579                self.persist_tka_chain(&synced).await;
580
581                self.tka_synced = Some(synced);
582            }
583            Ok(None) => {
584                // Control has no lock for us (no genesis / disabled). Clear any authority we were
585                // previously enforcing — symmetric with the disable path — so a transition to
586                // "no lock" stops dropping peers. Not an error.
587                if self.tka_synced.is_some() {
588                    tracing::info!("TKA sync: control reports no lock; clearing enforcement");
589                    self.tka_synced = None;
590                }
591                // Outside that guard for the same reason as the disable path: a chain an earlier
592                // process persisted outlives the synced state this one holds.
593                self.tka_chain.discard().await;
594                self.tka_authority.send_replace(None);
595            }
596            Err(e) => {
597                // Transport or verify failure: log and leave the prior authority in place (a failed
598                // sync must not drop enforcement — that would fail OPEN). NEVER errors the netmap.
599                // The next netmap update re-triggers a sync attempt.
600                tracing::warn!(error = %e, "TKA sync failed; keeping prior enforcement state");
601            }
602        }
603    }
604
605    /// Persist the freshly-synced AUM chain next to the cached netmap, so a cold start can vouch for
606    /// the peers in that netmap ([`load_cached_netmap`]).
607    ///
608    /// Written only where the netmap itself is written: with no
609    /// [`netmap_cache_dir`](ts_control::Config::netmap_cache_dir) there is nothing to vouch for, and
610    /// with the `cache-network-maps` grant absent or overridden nothing is cached either, so the
611    /// chain is removed instead of written — the same withdrawal rule `NetmapCache::observe` applies
612    /// to the netmap. The grant is read from the last self node control sent, which is the same
613    /// source `observe` reads it from: nothing is ever cached before one arrives, so "no self node
614    /// yet" is also "nothing to vouch for". A write failure is logged inside the cache and changes
615    /// nothing: the next cold start simply withholds the cached peers.
616    async fn persist_tka_chain(&mut self, synced: &crate::tka_sync::SyncedTka) {
617        // Scoped so the `watch` borrow is dropped before the awaits below (it is not `Send`).
618        let caching_granted = {
619            let self_node = self.self_node.borrow();
620            self_node
621                .as_ref()
622                .is_some_and(|node| ts_control::netmap_caching_enabled(&node.cap_map))
623        };
624        if !caching_granted {
625            self.tka_chain.discard().await;
626            return;
627        }
628
629        match crate::tka_sync::encode_chain(&synced.store, synced.oldest) {
630            Some(blob) => self.tka_chain.store(&blob).await,
631            None => {
632                // The store cannot be walked genesis→head, so there is no chain to persist. Drop any
633                // older one rather than leaving a chain that no longer matches what we enforce.
634                tracing::warn!("TKA: synced chain is not walkable; not persisting it");
635                self.tka_chain.discard().await;
636            }
637        }
638    }
639
640    fn with_self_node<F, R>(&self, f: F) -> impl Future<Output = Option<R>> + use<F, R>
641    where
642        F: FnOnce(&Node) -> R,
643    {
644        let mut sub = self.self_node.subscribe();
645        let mut shutdown = self.params.env.shutdown.clone();
646
647        async move {
648            tokio::select! {
649                _ = shutdown.wait_for(|x| *x) => {
650                    None
651                },
652                node = sub.wait_for(Option::is_some) => {
653                    Some(f(node.ok()?.as_ref()?))
654                },
655            }
656        }
657    }
658}
659
660/// Apply Go's sticky `PopBrowserURL` semantics to the consent-URL `watch` cell.
661///
662/// Control sends `MapResponse.PopBrowserURL` empty on nearly every netmap update, so the cell is
663/// updated ONLY when `incoming` is a non-empty URL that differs from the cell's current value —
664/// Go's `direct.go` guard `u != "" && u != sess.lastPopBrowserURL`. The cell is **never reset to
665/// `None`** by an empty/absent update — the running-node consent URL is sticky for the session.
666/// Updating unconditionally would thrash the cell to `None` on every tick and coalesce the URL away
667/// for a `watch`/bus subscriber.
668///
669/// The dedupe is in-place via [`watch::Sender::send_if_modified`] — the cell's own value is the
670/// "last URL sent" (this sticky path is its only writer), so no separate mirror field is needed and
671/// the watch is woken only on a genuine change (Go's `sess.lastPopBrowserURL` role, for free). This
672/// matches the [`send_if_modified`](watch::Sender::send_if_modified) idiom already used for the
673/// device-state cell in this handler.
674///
675/// Factored out of the netmap-update handler so the (easy-to-regress) sticky logic is unit-testable
676/// against a plain `watch` channel without standing up the actor.
677fn sticky_update_pop_browser_url(
678    cell: &watch::Sender<Option<url::Url>>,
679    incoming: Option<&url::Url>,
680) {
681    if let Some(url) = incoming {
682        cell.send_if_modified(|current| {
683            if current.as_ref() == Some(url) {
684                false
685            } else {
686                *current = Some(url.clone());
687                true
688            }
689        });
690    }
691}
692
693/// Map a mid-session re-auth URL surfaced by the control client onto the device-state cell.
694///
695/// The control client's live map-poll loop publishes an `Option<url::Url>` into a `watch` cell when
696/// a re-register hits `MachineNotAuthorized` (the node key expired/was revoked mid-session — see
697/// [`ts_control::AsyncControlClient::connect`]'s `auth_url_tx`). `ts_control` cannot name
698/// [`DeviceState`] (it must not depend on this crate), so this bridge fn does the translation:
699/// a `Some(url)` sets [`DeviceState::NeedsLogin`]`(url)` so the IPN bus derives `browse_to_url` and
700/// the embedder can prompt the user, exactly like the initial-registration `check_auth` path.
701///
702/// **Only `Some` drives a transition; `None` is ignored here.** The clear back to
703/// [`DeviceState::Running`] is owned by the netmap self-node handler (the next good self-node flips
704/// it — see the `StreamMessage::Next` arm), which is the authoritative "we are up again" signal; an
705/// independent `None`-clear in this bridge could race that and is unnecessary. The
706/// [`send_if_modified`](watch::Sender::send_if_modified) guard fires the watch only on a genuine
707/// state change (it is a no-op when the cell already holds `NeedsLogin(url)` for the same URL), so a
708/// re-auth URL re-surfaced across retries does not thrash the cell — mirroring the device-state
709/// dedupe in the netmap handler.
710///
711/// Factored out so the (regress-prone) map-and-guard is unit-testable against a plain `watch`
712/// channel without standing up the actor (mirrors [`sticky_update_pop_browser_url`]).
713/// **Auto-reauth ownership.** While an automatic re-auth is in flight (the cell holds
714/// [`DeviceState::Reauthenticating`]), this bridge must NOT downgrade it to `NeedsLogin`. An
715/// auto-reauth's rotated re-register surfaces an auth URL through the SAME `auth_url_tx` cell this
716/// bridge watches, but on a headless / auth-key node there is no human to visit it — flipping to
717/// `NeedsLogin` would surface a misleading `browse_to_url` AND, by moving the cell off
718/// `Reauthenticating`, let the next expired self-node re-fire reauth (a second node-key rotation that
719/// loses the original `OldNodeKey` anchor). The auto-reauth path owns the cell until it recovers to
720/// `Running` (the netmap self-node handler) or its circuit breaker trips it to `Expired`; this bridge
721/// stands down for the duration.
722pub(crate) fn bridge_reauth_url_to_state(
723    state_tx: &watch::Sender<crate::DeviceState>,
724    incoming: Option<&url::Url>,
725) {
726    if let Some(url) = incoming {
727        let next = crate::DeviceState::NeedsLogin(url.clone());
728        state_tx.send_if_modified(|current| {
729            // Do not clobber an in-flight automatic re-auth (see the ownership note above): leave
730            // `Reauthenticating` untouched so it can recover to `Running` or trip to `Expired` on its
731            // own terms.
732            if *current == crate::DeviceState::Reauthenticating || *current == next {
733                false
734            } else {
735                *current = next.clone();
736                true
737            }
738        });
739    }
740}
741
742/// What to do when control delivers a self-node whose node-key expiry has passed — the decision
743/// behind the [`StreamMessage::Next`] handler's expiry branch, factored into a pure function so the
744/// full input matrix is unit-testable (mirrors [`bridge_reauth_url_to_state`] being pure).
745#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub(crate) enum ExpiryAction {
747    /// The key is not expired — the node is up. (`→ DeviceState::Running`.)
748    Running,
749    /// The key expired and auto-reauth is permitted (auth key retained, reauth enabled, TKA NOT
750    /// enforcing): rotate the node key + re-register (`→ DeviceState::Reauthenticating` +
751    /// `Command::Reauth`).
752    Reauthenticate,
753    /// The key expired and auto-reauth is NOT permitted (no auth key, reauth disabled, or TKA
754    /// enforcing): fall back to today's terminal behavior (`→ DeviceState::Expired`).
755    Expired,
756}
757
758/// Decide the action for an expired-or-not self node (pure; the live handler at
759/// [`StreamMessage::Next`] applies it). Go's `ipnlocal` runs `doLogin` (rotate the node key +
760/// re-register with the stored auth key) when an auth-key node's key expires; this fork does the
761/// same, gated by three safety conditions:
762///
763/// - `key_expired` — control reported the self-node's key expiry is in the past.
764/// - `has_auth_key` — a usable auth key is retained for a non-interactive re-register (without one
765///   there is nothing to re-register with → fall back to `Expired`, today's behavior).
766/// - `reauth_enabled` — the `reauth_on_expiry` config opt-out is on (default true).
767/// - `tka_active` — Tailnet Lock enforcement is currently active. **Hard safety gate:** a node-key
768///   rotation on a locked tailnet would install an UNSIGNED key, locking the node out of locked
769///   peers (the TKA re-sign is a separate follow-up — see `keystate.rs` `rotate_node_key`). So when
770///   the lock is enforcing, never rotate — fall back to `Expired`.
771///
772/// Truth table: not expired → `Running`; expired AND auth-key AND reauth-enabled AND NOT TKA →
773/// `Reauthenticate`; otherwise → `Expired`.
774pub(crate) fn expiry_action(
775    key_expired: bool,
776    has_auth_key: bool,
777    reauth_enabled: bool,
778    tka_active: bool,
779) -> ExpiryAction {
780    if !key_expired {
781        return ExpiryAction::Running;
782    }
783    if has_auth_key && reauth_enabled && !tka_active {
784        ExpiryAction::Reauthenticate
785    } else {
786        ExpiryAction::Expired
787    }
788}
789
790/// The outcome of one step of the bounded reauth sub-state-machine: the device state to publish, the
791/// updated consecutive-attempt counter, and whether to fire the one-shot `Command::Reauth` this step.
792#[derive(Debug, Clone, Copy, PartialEq, Eq)]
793pub(crate) struct ReauthStep {
794    /// The device state to publish for this self-node.
795    pub next: ReauthState,
796    /// The consecutive-failed-reauth counter after this step (the caller stores it back).
797    pub attempts: u32,
798    /// Whether to fire the one-shot `Command::Reauth` (rotate + re-register) this step. Set ONLY on
799    /// the transition INTO reauthenticating, so the node key rotates at most once per episode.
800    pub fire_reauth: bool,
801}
802
803/// The device state a [`ReauthStep`] resolves to — the subset of [`DeviceState`](crate::DeviceState)
804/// the circuit breaker can produce. Kept as its own enum so the breaker stays pure (no dependency on
805/// the URL-carrying `DeviceState` variants) and the truth table is exhaustively testable.
806#[derive(Debug, Clone, Copy, PartialEq, Eq)]
807pub(crate) enum ReauthState {
808    /// The node is up — `→ DeviceState::Running`.
809    Running,
810    /// An automatic re-auth is in flight — `→ DeviceState::Reauthenticating`.
811    Reauthenticating,
812    /// Terminal expiry — `→ DeviceState::Expired` (either the non-reauth path or the breaker tripped).
813    Expired,
814}
815
816/// One step of the bounded reauth sub-state-machine (pure; the [`StreamMessage::Next`] handler stores
817/// the result back into [`ControlRunner::reauth_attempts`] and publishes [`ReauthStep::next`]). This
818/// is the circuit breaker for the design's deferred-question Q1: a one-shot / already-consumed auth
819/// key cannot re-register, so the `Reauthenticate` path must settle rather than loop forever.
820///
821/// Inputs:
822/// - `action` — the [`expiry_action`] verdict for this self-node.
823/// - `already_reauthing` — whether the published state is currently
824///   [`DeviceState::Reauthenticating`](crate::DeviceState::Reauthenticating) (the prior reauth has
825///   not yet recovered the node).
826/// - `attempts` — the current consecutive-failed-reauth counter.
827///
828/// Rules:
829/// - `Running` → reset the counter to `0` (the node recovered) and report `Running`.
830/// - `Reauthenticate` while NOT already reauthing → ENTER reauthenticating: counter `= 1`, fire the
831///   one-shot reauth.
832/// - `Reauthenticate` while ALREADY reauthing → the prior reauth did not recover; COUNT it (counter
833///   `+= 1`) and do NOT re-fire (a second rotation would lose the original `OldNodeKey` anchor). At
834///   [`MAX_REAUTH_ATTEMPTS`] the breaker trips to terminal `Expired`; below the cap, stay reauthing.
835/// - `Expired` → terminal; the counter is irrelevant (this path never armed the reauth machine), so
836///   it is reset to `0`.
837pub(crate) fn reauth_circuit_step(
838    action: ExpiryAction,
839    already_reauthing: bool,
840    attempts: u32,
841) -> ReauthStep {
842    match action {
843        ExpiryAction::Running => ReauthStep {
844            next: ReauthState::Running,
845            attempts: 0,
846            fire_reauth: false,
847        },
848        ExpiryAction::Reauthenticate if !already_reauthing => ReauthStep {
849            next: ReauthState::Reauthenticating,
850            attempts: 1,
851            fire_reauth: true,
852        },
853        ExpiryAction::Reauthenticate => {
854            let attempts = attempts.saturating_add(1);
855            if attempts >= MAX_REAUTH_ATTEMPTS {
856                ReauthStep {
857                    next: ReauthState::Expired,
858                    attempts,
859                    fire_reauth: false,
860                }
861            } else {
862                ReauthStep {
863                    next: ReauthState::Reauthenticating,
864                    attempts,
865                    fire_reauth: false,
866                }
867            }
868        }
869        ExpiryAction::Expired => ReauthStep {
870            next: ReauthState::Expired,
871            attempts: 0,
872            fire_reauth: false,
873        },
874    }
875}
876
877/// The classification of SELF against the active network lock — the observability analog of Go
878/// `tkaFilterNetmapLocked`'s self check (which raises a `LockedOut` health warning).
879#[derive(Debug, Clone, PartialEq, Eq)]
880enum SelfLockVerdict {
881    /// Self carries no key-signature at all (empty). The common "not signed yet" case: the node
882    /// simply has not been signed for this lock — not locked out, just unsigned.
883    Unsigned,
884    /// Self's key-signature is authorized by the active lock; nothing to warn about.
885    Authorized,
886    /// Self has a key-signature but the lock does NOT authorize it (the message is the verify
887    /// error). The operator-facing `LockedOut` condition: locked peers will reject this node.
888    LockedOut(String),
889}
890
891/// Classify a node key + its key-signature against `authority` (pure: verify-and-classify, no
892/// logging, no I/O). Takes only the two fields it needs — not the whole `Node` — so the decision is
893/// unit-testable without constructing a full `Node` or standing up the actor.
894fn self_lock_verdict(
895    node_key: &ts_keys::NodePublicKey,
896    key_signature: &[u8],
897    authority: &ts_tka::Authority,
898) -> SelfLockVerdict {
899    // Mirror the peer path (`peer_tracker` `tka_snapshot_admits`): treat an empty signature as
900    // "unsigned" rather than the `LockedOut` bucket Go's `NodeKeyAuthorized` would put a nil sig in
901    // (it errors at decode). This is a deliberate, narrow divergence from a literal Go port: it
902    // avoids `warn`-spam on a lock that simply has not signed this node yet, and keeps self and peer
903    // classification consistent.
904    if key_signature.is_empty() {
905        return SelfLockVerdict::Unsigned;
906    }
907    match authority.node_key_authorized(&node_key.to_bytes(), key_signature) {
908        Ok(()) => SelfLockVerdict::Authorized,
909        Err(e) => SelfLockVerdict::LockedOut(e.to_string()),
910    }
911}
912
913/// Emit the self-locked-out observability signal (Go `tkaFilterNetmapLocked`'s self check → a
914/// `LockedOut` health warning): classify SELF against the freshly-synced `authority` and log.
915///
916/// This is **observability, not enforcement** — self never enters the peer db, so the lock can never
917/// filter our own node out of the netmap. But a self the lock does not authorize means this node's
918/// key-signature is absent or invalid for the active lock, so it cannot prove itself to locked peers
919/// (they will drop it); surfacing that lets an operator notice and re-sign. A never-signed node
920/// (empty signature) logs at `info`, distinct from a present-but-invalid signature (`warn`), so the
921/// common unsigned case does not spam a warning. This fork has no health subsystem, so the operator
922/// signal is a `tracing` event (its observability channel).
923fn log_self_lockout(self_node: &Node, authority: &ts_tka::Authority) {
924    match self_lock_verdict(&self_node.node_key, &self_node.key_signature, authority) {
925        SelfLockVerdict::Unsigned => tracing::info!(
926            "TKA: this node has no key-signature for the active lock; it cannot prove itself to \
927             locked peers until control signs it (not locked out, just unsigned)"
928        ),
929        SelfLockVerdict::Authorized => {
930            tracing::debug!("TKA: self node-key is authorized by the active lock")
931        }
932        SelfLockVerdict::LockedOut(error) => tracing::warn!(
933            %error,
934            "TKA self locked out: this node's key-signature is not authorized by the active \
935             network lock; locked peers will reject it until control re-signs this node \
936             (Go LockedOut)"
937        ),
938    }
939}
940
941// The `#[kameo::messages]` macro generates message structs whose fields mirror the method params;
942// those generated fields carry no doc and can't take attributes, so wrap in a module where
943// missing-docs is allowed (same pattern as PeerTracker's `msg_impl`). The generated message structs
944// are re-exported so callers keep referencing them at `control_runner::<Name>`.
945pub use msg_impl::*;
946
947#[allow(missing_docs)]
948mod msg_impl {
949    use kameo::{message::Context, reply::DelegatedReply};
950
951    use super::*;
952
953    #[kameo::messages]
954    impl ControlRunner {
955        /// Fetch the IPv4 address for this tailscale device.
956        #[message(ctx)]
957        pub fn ipv4(
958            &self,
959            ctx: &mut Context<Self, DelegatedReply<Option<Ipv4Addr>>>,
960        ) -> DelegatedReply<Option<Ipv4Addr>> {
961            let (deleg, replier) = ctx.reply_sender();
962
963            if let Some(replier) = replier {
964                let fut = self.with_self_node(|node| node.tailnet_address.ipv4.addr());
965
966                tokio::spawn(async move {
967                    let ip = fut.await;
968                    replier.send(ip);
969                });
970            }
971
972            deleg
973        }
974
975        /// Fetch the IPv6 address for this tailscale device.
976        #[message(ctx)]
977        pub fn ipv6(
978            &self,
979            ctx: &mut Context<Self, DelegatedReply<Option<Ipv6Addr>>>,
980        ) -> DelegatedReply<Option<Ipv6Addr>> {
981            let (deleg, replier) = ctx.reply_sender();
982
983            if let Some(replier) = replier {
984                let fut = self.with_self_node(|node| node.tailnet_address.ipv6.addr());
985
986                tokio::spawn(async move {
987                    let ip = fut.await;
988                    replier.send(ip);
989                });
990            }
991
992            deleg
993        }
994
995        /// Fetch the self node for this tailscale device.
996        #[message(ctx)]
997        pub fn self_node(
998            &self,
999            ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
1000        ) -> DelegatedReply<Option<Node>> {
1001            let (deleg, replier) = ctx.reply_sender();
1002
1003            if let Some(replier) = replier {
1004                let node = self.with_self_node(|node| node.clone());
1005
1006                tokio::spawn(async move {
1007                    let node = node.await;
1008                    replier.send(node)
1009                });
1010            }
1011
1012            deleg
1013        }
1014
1015        /// Fetch the current Tailscale SSH policy, if control has pushed one.
1016        ///
1017        /// Returns `None` when control has not sent an SSH policy (the SSH server treats this as
1018        /// deny-all — fail-closed). Unlike `self_node` this does not block waiting
1019        /// for a value: an absent policy is a legitimate, immediate answer.
1020        #[message]
1021        pub fn current_ssh_policy(&self) -> Option<SshPolicy> {
1022            self.ssh_policy.borrow().clone()
1023        }
1024
1025        /// Fetch the current Tailnet Lock status, if control has pushed one.
1026        ///
1027        /// Returns `None` when control has sent no `TKAInfo` (tailnet lock not in use / no change seen).
1028        #[message]
1029        pub fn current_tka_status(&self) -> Option<TkaStatus> {
1030            self.tka.borrow().clone()
1031        }
1032
1033        /// Read up to `limit` entries of the Tailnet-Lock update-chain log, **head-first** (newest →
1034        /// oldest), from the locally-synced AUM chain (Go `NetworkLockLog`).
1035        ///
1036        /// A **pure local read** — no crypto, no mutation, no control round-trip: it walks the
1037        /// already-verified `SyncedTka` store this actor owns. Returns an empty `Vec` when no lock is
1038        /// synced (Go's `b.tka == nil`). Synchronous (no spawn), like `current_tka_status` — the
1039        /// chain is in memory.
1040        #[message]
1041        pub fn tka_log(&self, limit: usize) -> Vec<crate::tka_sync::TkaLogEntry> {
1042            let Some(synced) = self.tka_synced.as_ref() else {
1043                return Vec::new();
1044            };
1045            crate::tka_sync::tka_log_entries(&synced.store, synced.oldest, limit)
1046        }
1047
1048        /// Sign `node_key` directly with this node's network-lock key and submit the signature to
1049        /// control (Go `tka.sign` for the Direct case → `tkaSubmitSignature`).
1050        ///
1051        /// Builds a `Direct` [`NodeKeySignature`](ts_tka::NodeKeySignature) via
1052        /// [`sign_direct`](ts_tka::NodeKeySignature::sign_direct) over this node's inner ed25519
1053        /// network-lock signing key, serializes it (raw CBOR), and POSTs it to `/machine/tka/sign`.
1054        /// Mirrors `set_dns`/`get_certificate`: clones the control config + node keys into a spawned
1055        /// task (delegated reply, so the round-trip doesn't block the mailbox) over a fresh Noise
1056        /// channel.
1057        ///
1058        /// **Posture: this only *submits* a signature to control — it does NOT mutate the local
1059        /// [`Authority`](ts_tka::Authority).** The local trusted-key state advances solely through the
1060        /// existing verified-sync path (`sync_tka` → `VerifiedAumChain::verify`); a `tka_sign` success
1061        /// is acknowledged to the caller, and the resulting AUM is picked up on the next netmap-driven
1062        /// sync. Verify-and-log is unchanged.
1063        #[message(ctx)]
1064        pub fn tka_sign(
1065            &self,
1066            ctx: &mut Context<Self, DelegatedReply<Result<(), TkaSyncError>>>,
1067            node_key: [u8; 32],
1068        ) -> DelegatedReply<Result<(), TkaSyncError>> {
1069            let (deleg, replier) = ctx.reply_sender();
1070
1071            if let Some(replier) = replier {
1072                let config = self.params.config.clone();
1073                let keys = self.params.env.keys.clone();
1074                tokio::spawn(async move {
1075                    // Sign the node key with our network-lock key, then submit the raw-CBOR NKS.
1076                    let nks = ts_tka::NodeKeySignature::sign_direct(
1077                        &node_key,
1078                        &keys.network_lock_keys.private.signing_key(),
1079                    );
1080                    let req = ts_control::TkaSubmitSignatureRequest {
1081                        // node_key + version are stamped by the RPC client from `keys`.
1082                        version: Default::default(),
1083                        node_key: keys.node_keys.public,
1084                        signature: nks.serialize(),
1085                    };
1086                    let result = tka_submit_signature(
1087                        &config.server_url,
1088                        &keys,
1089                        req,
1090                        config.allow_http_key_fetch,
1091                    )
1092                    .await
1093                    .map(|_response| ());
1094                    replier.send(result);
1095                });
1096            }
1097
1098            deleg
1099        }
1100
1101        /// Disable Tailnet Lock by presenting the disablement secret to control (Go
1102        /// `tka.disable` → `/machine/tka/disable`).
1103        ///
1104        /// Targets the **current** authority head (read from the cached [`TkaStatus`]); the caller
1105        /// supplies the `disablement_secret` out of band (it is the operator-held capability that
1106        /// authorizes turning the lock off). Mirrors `tka_sign`: clones config + keys into a spawned
1107        /// task (delegated reply). Returns [`TkaSyncError::Unsupported`] when there is no known TKA
1108        /// head (lock not in use / control hasn't pushed a status), since there is nothing to disable.
1109        ///
1110        /// **Submit-only, like `tka_sign`:** this POSTs the disablement to control and does NOT mutate
1111        /// the local [`Authority`](ts_tka::Authority). Control acts on the disablement; this node
1112        /// observes the result through the existing verified-sync path. Verify-and-log unchanged.
1113        #[message(ctx)]
1114        pub fn tka_disable(
1115            &self,
1116            ctx: &mut Context<Self, DelegatedReply<Result<(), TkaSyncError>>>,
1117            disablement_secret: Vec<u8>,
1118        ) -> DelegatedReply<Result<(), TkaSyncError>> {
1119            let (deleg, replier) = ctx.reply_sender();
1120
1121            if let Some(replier) = replier {
1122                // Read the current head from the cached status BEFORE the spawn (can't borrow &self
1123                // across the await). No head ⇒ no lock to disable ⇒ Unsupported.
1124                let head = self.tka.borrow().as_ref().map(|s| s.head.clone());
1125                let config = self.params.config.clone();
1126                let keys = self.params.env.keys.clone();
1127                tokio::spawn(async move {
1128                    let result = match head {
1129                        Some(head) => {
1130                            let req = ts_control::TkaDisableRequest {
1131                                // node_key + version are stamped by the RPC client from `keys`.
1132                                version: Default::default(),
1133                                node_key: keys.node_keys.public,
1134                                head,
1135                                disablement_secret,
1136                            };
1137                            tka_disable(&config.server_url, &keys, req, config.allow_http_key_fetch)
1138                                .await
1139                                .map(|_response| ())
1140                        }
1141                        None => Err(TkaSyncError::Unsupported),
1142                    };
1143                    replier.send(result);
1144                });
1145            }
1146
1147            deleg
1148        }
1149
1150        /// Initialize Tailnet Lock with this node as the sole initial trusted key, gated by
1151        /// `disablement_secret` (Go `LocalClient.NetworkLockInit` — the "lock yourself in" case).
1152        ///
1153        /// Builds + signs a genesis Checkpoint AUM whose only trusted key is this node's network-lock
1154        /// public key (votes 1) and whose single DisablementValue is `disablement_value(secret)`, then
1155        /// drives the two-phase init: `tka/init/begin` (submit the genesis) → if control needs no
1156        /// further node signatures (`NeedSignatures` empty, the case when this node is the only key) →
1157        /// `tka/init/finish` carrying the raw `disablement_secret` as `SupportDisablement`. Mirrors
1158        /// `tka_sign`/`tka_disable`: cloned config + keys into a spawned task (delegated reply).
1159        ///
1160        /// If control returns a non-empty `NeedSignatures` (other nodes must be re-signed under the new
1161        /// lock — a multi-node tailnet), this returns [`TkaSyncError::Unsupported`]: re-signing each
1162        /// listed node (incl. the Rotation-key case) is a larger flow deferred to a fuller
1163        /// `tka_init(keys, secrets)` — the single-node lock-init is the shipped subset.
1164        ///
1165        /// **Submit-only**, like `tka_sign`/`tka_disable`: this creates the lock at control and does
1166        /// NOT seed the local [`Authority`](ts_tka::Authority) — the node picks up the new lock through
1167        /// the existing verified netmap-sync (control pushes a `TKAInfo`, `maybe_sync_tka` bootstraps
1168        /// the genesis through `VerifiedAumChain::verify`). Verify-and-log posture unchanged.
1169        #[message(ctx)]
1170        pub fn tka_init(
1171            &self,
1172            ctx: &mut Context<Self, DelegatedReply<Result<(), TkaSyncError>>>,
1173            disablement_secret: Vec<u8>,
1174        ) -> DelegatedReply<Result<(), TkaSyncError>> {
1175            let (deleg, replier) = ctx.reply_sender();
1176
1177            if let Some(replier) = replier {
1178                let config = self.params.config.clone();
1179                let keys = self.params.env.keys.clone();
1180                tokio::spawn(async move {
1181                    let result = tka_init_run(&config, &keys, disablement_secret).await;
1182                    replier.send(result);
1183                });
1184            }
1185
1186            deleg
1187        }
1188
1189        /// The cert-eligible DNS names from control's netmap DNS config (Go `nm.DNS.CertDomains`).
1190        ///
1191        /// Returns an empty `Vec` when control has sent no DNS config, or one carrying no cert
1192        /// domains (an empty list is a legitimate, immediate answer — like `current_ssh_policy`, this
1193        /// does not block waiting for a value).
1194        #[message]
1195        pub fn cert_domains(&self) -> Vec<String> {
1196            self.cert_domains.borrow().clone()
1197        }
1198
1199        /// The full DNS config from control's netmap (Go `netmap.NetworkMap.DNS`), or `None` when
1200        /// control has sent no DNS config yet. An immediate answer (does not block); the facade
1201        /// surfaces this for `Device::dns_config` (the daemon's `tnet dns status`).
1202        #[message]
1203        pub fn dns_config(&self) -> Option<ts_control::DnsConfig> {
1204            self.dns_config.borrow().clone()
1205        }
1206
1207        /// The interactive-login / consent URL control last asked this node to open
1208        /// (`MapResponse.PopBrowserURL`), or `None` when control has sent none. An immediate answer
1209        /// (does not block); the facade surfaces this for `Device::pop_browser_url`.
1210        #[message]
1211        pub fn pop_browser_url(&self) -> Option<url::Url> {
1212            self.pop_browser_url.borrow().clone()
1213        }
1214
1215        /// Subscribe to the interactive-login / consent URL cell (`MapResponse.PopBrowserURL`).
1216        ///
1217        /// Returns a [`watch::Receiver`] whose value is the latest running-node consent URL, used by
1218        /// [`Runtime::watch_ipn_bus`](crate::Runtime::watch_ipn_bus) to surface `browse_to_url`
1219        /// events mid-session. The cell is sticky (updated only on a new non-empty URL, never reset
1220        /// to `None` by an empty update — see the field docs), so a subscriber is not thrashed and a
1221        /// late subscriber sees the current URL. The initial value is `None` until control sends one.
1222        #[message(derive(Clone))]
1223        pub fn watch_browser_url(&self) -> watch::Receiver<Option<url::Url>> {
1224            self.pop_browser_url.subscribe()
1225        }
1226
1227        /// The latest network-conditions report (preferred DERP region + per-region latencies). An
1228        /// immediate answer (does not block); empty before the first DERP-latency measurement. The
1229        /// facade surfaces this for `Device::netcheck` (the daemon's `tnet netcheck`).
1230        #[message]
1231        pub fn netcheck(&self) -> crate::status::NetcheckReport {
1232            self.netcheck.borrow().clone()
1233        }
1234
1235        /// The lowest latency seen for each DERP region across the retained measurement history —
1236        /// the Rust analog of Go `netcheck.Client.RecentRegionLatency()`, and what
1237        /// [`Runtime::suggest_exit_node`](crate::Runtime::suggest_exit_node) ranks exit-node
1238        /// candidates on.
1239        ///
1240        /// Ranking on this rather than on the newest report is the whole point: every measurement
1241        /// this fork makes is *partial* (`ts_netcheck::Config::complete_threshold` ends it once
1242        /// three regions have answered), so the newest report names a handful of regions and a
1243        /// candidate homed anywhere else would be ranked against nothing — collapsing the
1244        /// suggestion to a uniform random pick. The union over the retained window gives every
1245        /// region a recent latency to be ranked on.
1246        ///
1247        /// The window (`DERP_HISTORY_MAX_AGE`) is re-applied *here*, at read time, not just when
1248        /// a measurement lands: the history is only pruned on arrival, so a node that has gone
1249        /// quiet must not serve latencies that have since aged out. An immediate answer (does not
1250        /// block); empty before the first DERP-latency measurement.
1251        #[message]
1252        pub fn recent_region_latency(&self) -> HashMap<ts_derp::RegionId, Duration> {
1253            best_recent(
1254                &self.derp_report_history,
1255                Instant::now(),
1256                DERP_HISTORY_MAX_AGE,
1257            )
1258        }
1259
1260        /// Request an OIDC ID token from control scoped to `audience` (workload-identity federation).
1261        ///
1262        /// Opens a fresh Noise channel and POSTs `/machine/id-token`; returns the signed JWT or an
1263        /// [`IdTokenError`]. Runs on a spawned task (delegated reply) so the actor mailbox isn't blocked
1264        /// for the round-trip.
1265        #[message(ctx)]
1266        pub fn fetch_id_token(
1267            &self,
1268            ctx: &mut Context<Self, DelegatedReply<Result<String, IdTokenError>>>,
1269            audience: String,
1270        ) -> DelegatedReply<Result<String, IdTokenError>> {
1271            let (deleg, replier) = ctx.reply_sender();
1272
1273            if let Some(replier) = replier {
1274                let config = self.params.config.clone();
1275                let keys = self.params.env.keys.clone();
1276                tokio::spawn(async move {
1277                    let result = ts_control::fetch_id_token(&config, &keys, &audience).await;
1278                    replier.send(result);
1279                });
1280            }
1281
1282            deleg
1283        }
1284
1285        /// Log this node out of the tailnet: deregister it by expiring its current node key.
1286        ///
1287        /// Mirrors `fetch_id_token`: clones the control config + node keys
1288        /// into a spawned task (delegated reply, so the round-trip doesn't block the mailbox) and
1289        /// re-POSTs `/machine/register` with a past expiry over a fresh Noise channel. This is a
1290        /// control-plane state change only — it does NOT stop this actor or tear down the datapath
1291        /// (the caller follows up with the normal runtime shutdown), and it does not touch the
1292        /// on-disk node key, so re-registering with the same key is the re-login path.
1293        #[message(ctx)]
1294        pub fn logout(
1295            &self,
1296            ctx: &mut Context<Self, DelegatedReply<Result<(), LogoutError>>>,
1297        ) -> DelegatedReply<Result<(), LogoutError>> {
1298            let (deleg, replier) = ctx.reply_sender();
1299
1300            if let Some(replier) = replier {
1301                let config = self.params.config.clone();
1302                let keys = self.params.env.keys.clone();
1303                tokio::spawn(async move {
1304                    let result = ts_control::logout(&config, &keys).await;
1305                    replier.send(result);
1306                });
1307            }
1308
1309            deleg
1310        }
1311
1312        /// Publish a DNS record for this node via control's `/machine/set-dns` (Go
1313        /// `LocalClient.SetDNS`).
1314        ///
1315        /// Mirrors `fetch_id_token`: clones the control config + node keys
1316        /// into a spawned task (delegated reply, so the round-trip doesn't block the mailbox) and
1317        /// POSTs the record over a fresh Noise channel. Go's `SetDNS` is `TXT`-only (its sole use is
1318        /// the ACME DNS-01 `_acme-challenge` record); the record type is fixed to `"TXT"` here to
1319        /// match, so the surfaced API takes only `name` + `value`.
1320        #[message(ctx)]
1321        pub fn set_dns(
1322            &self,
1323            ctx: &mut Context<Self, DelegatedReply<Result<(), SetDnsError>>>,
1324            name: String,
1325            value: String,
1326        ) -> DelegatedReply<Result<(), SetDnsError>> {
1327            let (deleg, replier) = ctx.reply_sender();
1328
1329            if let Some(replier) = replier {
1330                let config = self.params.config.clone();
1331                let keys = self.params.env.keys.clone();
1332                tokio::spawn(async move {
1333                    let result = ts_control::set_dns(&config, &keys, &name, "TXT", &value).await;
1334                    replier.send(result);
1335                });
1336            }
1337
1338            deleg
1339        }
1340    }
1341
1342    /// The reply type of the [`get_cert_pair`](ControlRunner::get_cert_pair) message: the issued
1343    /// `(cert_chain_pem, key_pem)` PEM pair (the `tnet cert` surface) or a [`ts_control::CertError`].
1344    /// Aliased so the message's `Context` type stays under clippy's `type_complexity` bar (the
1345    /// nested `Result<(String, String), _>` trips it inline).
1346    #[cfg(feature = "acme")]
1347    pub type CertPairReply = Result<(String, String), ts_control::CertError>;
1348
1349    // The `acme`-gated cert-issuance message lives in its own `#[kameo::messages]` impl block so the
1350    // proc-macro never sees it in a non-`acme` build (a `#[cfg]` *inside* a single messages-impl
1351    // block is not honored by the macro's generated dispatch — it would emit a `GetCertificate`
1352    // handler calling a `get_certificate` method that the same `#[cfg]` strips). A separate gated
1353    // block keeps the default build clean.
1354    #[cfg(feature = "acme")]
1355    #[kameo::messages]
1356    impl ControlRunner {
1357        /// Issue a real Let's Encrypt certificate for this node's MagicDNS `name` via the
1358        /// client-side ACME DNS-01 engine (`acme` feature).
1359        ///
1360        /// Mirrors `fetch_id_token`: clones the control config + node keys
1361        /// into a spawned task (delegated reply, so the round-trip doesn't block the mailbox), loads
1362        /// or generates the ACME account key, and runs issuance against Let's Encrypt production,
1363        /// publishing the DNS-01 challenge TXT through the node's `POST /machine/set-dns` RPC.
1364        ///
1365        /// The account key is loaded from [`ts_keys::NodeState::acme_account_key`] (PKCS#8 DER) when
1366        /// present, so the same ACME account persists across renewals; otherwise an ephemeral key is
1367        /// generated for this call only (a fresh ACME account each issuance — acceptable for v1; LE
1368        /// allows it). Persisting a generated key back into the key file is the embedder's job (no
1369        /// write-back path here). SaaS-only: against a self-hosted control plane the set-dns
1370        /// publish 501s.
1371        #[message(ctx)]
1372        pub fn get_certificate(
1373            &self,
1374            ctx: &mut Context<
1375                Self,
1376                DelegatedReply<Result<ts_control::tls::CertifiedKey, ts_control::CertError>>,
1377            >,
1378            name: String,
1379        ) -> DelegatedReply<Result<ts_control::tls::CertifiedKey, ts_control::CertError>> {
1380            let (deleg, replier) = ctx.reply_sender();
1381
1382            if let Some(replier) = replier {
1383                let config = self.params.config.clone();
1384                let keys = self.params.env.keys.clone();
1385                tokio::spawn(async move {
1386                    let result = issue_certificate(&config, &keys, &name).await;
1387                    replier.send(result);
1388                });
1389            }
1390
1391            deleg
1392        }
1393
1394        /// Issue a real Let's Encrypt certificate for this node's MagicDNS `name` and return the
1395        /// **PEM pair** — `(cert_chain_pem, key_pem)` — for writing the on-disk `.crt` + `.key`
1396        /// (the daemon's `tnet cert`, Go's `LocalClient.CertPair`). `acme` feature.
1397        ///
1398        /// Identical issuance to [`get_certificate`](Self::get_certificate) (same client-side ACME
1399        /// DNS-01 flow, same set-dns publish, same account-key handling), only the *shape* of the
1400        /// result differs: this surfaces the raw chain + leaf-key PEMs instead of the opaque
1401        /// [`CertifiedKey`](ts_control::tls::CertifiedKey). The leaf **private key** PEM is the
1402        /// second tuple element and is NEVER logged — the spawned task sends it straight back to the
1403        /// replier. SaaS-only: against a self-hosted control plane the set-dns publish 501s.
1404        #[message(ctx)]
1405        pub fn get_cert_pair(
1406            &self,
1407            ctx: &mut Context<Self, DelegatedReply<CertPairReply>>,
1408            name: String,
1409        ) -> DelegatedReply<CertPairReply> {
1410            let (deleg, replier) = ctx.reply_sender();
1411
1412            if let Some(replier) = replier {
1413                let config = self.params.config.clone();
1414                let keys = self.params.env.keys.clone();
1415                tokio::spawn(async move {
1416                    let result = issue_cert_pair(&config, &keys, &name).await;
1417                    replier.send(result);
1418                });
1419            }
1420
1421            deleg
1422        }
1423    }
1424}
1425
1426/// The `tka_init` body (the genesis-build + two-phase init/begin→init/finish choreography),
1427/// factored out of the actor handler so it runs in the spawned task. See [`ControlRunner::tka_init`].
1428///
1429/// "Lock yourself in": the genesis trusts only this node's network-lock key (votes 1) and stores one
1430/// DisablementValue = `disablement_value(secret)`. On a non-empty `NeedSignatures` (multi-node
1431/// tailnet needing re-signs) it returns [`TkaSyncError::Unsupported`] — the single-node subset.
1432async fn tka_init_run(
1433    config: &ts_control::Config,
1434    keys: &ts_keys::NodeState,
1435    disablement_secret: Vec<u8>,
1436) -> Result<(), TkaSyncError> {
1437    // Build the genesis: this node's NL public key as the sole trusted key, one disablement value.
1438    let nl_public = keys.network_lock_keys.public.to_bytes().to_vec();
1439    let genesis_key = ts_tka::AumKey {
1440        kind: ts_tka::KeyKind::Ed25519,
1441        votes: 1,
1442        public: nl_public,
1443        meta: Vec::new(),
1444    };
1445    let dvalue = ts_tka::disablement_value(&disablement_secret).to_vec();
1446    let mut genesis = ts_tka::Aum::new_genesis_checkpoint(vec![genesis_key], vec![dvalue])
1447        // A malformed genesis is a local construction bug, not a transient RPC failure — surface it as a
1448        // coarse internal error rather than NetworkError (which would invite a pointless retry).
1449        .map_err(|_| TkaSyncError::Internal(ts_control::TkaSyncInternalErrorKind::SerDe))?;
1450    genesis.sign(&keys.network_lock_keys.private.signing_key());
1451
1452    // Phase 1: submit the genesis. node_key + version are stamped by the RPC client from `keys`.
1453    let begin_req = ts_control::TkaInitBeginRequest {
1454        version: Default::default(),
1455        node_key: keys.node_keys.public,
1456        genesis_aum: genesis.serialize(),
1457    };
1458    let begin_resp = tka_init_begin(
1459        &config.server_url,
1460        keys,
1461        begin_req,
1462        config.allow_http_key_fetch,
1463    )
1464    .await?;
1465
1466    // Single-node case only: control must need no further node signatures. A non-empty
1467    // NeedSignatures means other nodes must be re-signed under the new lock — deferred.
1468    if !begin_resp.need_signatures.is_empty() {
1469        tracing::warn!(
1470            need = begin_resp.need_signatures.len(),
1471            "tka_init: control requires re-signing other nodes; the multi-node init is not yet \
1472             implemented (single-node lock-init only)"
1473        );
1474        return Err(TkaSyncError::Unsupported);
1475    }
1476
1477    // Phase 2: finish, carrying the raw disablement secret as SupportDisablement (Go sends the raw
1478    // secret here; only the genesis stores its Argon2i hash).
1479    let finish_req = ts_control::TkaInitFinishRequest {
1480        version: Default::default(),
1481        node_key: keys.node_keys.public,
1482        signatures: std::collections::BTreeMap::new(),
1483        support_disablement: disablement_secret,
1484    };
1485    tka_init_finish(
1486        &config.server_url,
1487        keys,
1488        finish_req,
1489        config.allow_http_key_fetch,
1490    )
1491    .await
1492    .map(|_response| ())
1493}
1494
1495/// Load or generate the ACME account key, then issue a cert for `name` via set-dns DNS-01,
1496/// returning just the ready-to-serve [`CertifiedKey`](ts_control::tls::CertifiedKey) (the
1497/// `get_certificate` / `ListenTLS` path).
1498///
1499/// Thin wrapper over [`issue_cert_pair`] that drops the PEMs — one issuance, this caller just
1500/// doesn't need the on-disk pair. See [`issue_cert_pair`] for the account-key handling.
1501#[cfg(feature = "acme")]
1502async fn issue_certificate(
1503    config: &ts_control::Config,
1504    keys: &ts_keys::NodeState,
1505    name: &str,
1506) -> Result<ts_control::tls::CertifiedKey, ts_control::CertError> {
1507    issue_cert_pair_inner(config, keys, name)
1508        .await
1509        .map(|issued| issued.certified)
1510}
1511
1512/// Load or generate the ACME account key, then issue a cert for `name` via set-dns DNS-01,
1513/// returning the **PEM pair** `(cert_chain_pem, key_pem)` for the daemon's on-disk `.crt`/`.key`
1514/// (`tnet cert`, Go `LocalClient.CertPair`).
1515///
1516/// Same single issuance as [`issue_certificate`]; only the result shape differs. The leaf
1517/// **private key** PEM is the second element and is NEVER logged here.
1518#[cfg(feature = "acme")]
1519async fn issue_cert_pair(
1520    config: &ts_control::Config,
1521    keys: &ts_keys::NodeState,
1522    name: &str,
1523) -> Result<(String, String), ts_control::CertError> {
1524    issue_cert_pair_inner(config, keys, name)
1525        .await
1526        .map(|issued| (issued.cert_chain_pem, issued.key_pem))
1527}
1528
1529/// Shared issuance core for [`issue_certificate`] and [`issue_cert_pair`]: load (or generate) the
1530/// ACME account key, target Let's Encrypt production, and run one DNS-01 issuance, returning the
1531/// full [`IssuedCert`](ts_control::acme::IssuedCert) so each caller projects out what it needs (one
1532/// ACME order, two consumers).
1533///
1534/// Reuses the persisted [`ts_keys::NodeState::acme_account_key`] (PKCS#8 DER) when present so the
1535/// same Let's Encrypt account survives renewals; otherwise generates an ephemeral per-call key
1536/// (logged at debug — a new ACME account each issuance, with no write-back). Always targets Let's
1537/// Encrypt production ([`ts_control::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY`]). Never logs the leaf
1538/// private key.
1539#[cfg(feature = "acme")]
1540async fn issue_cert_pair_inner(
1541    config: &ts_control::Config,
1542    keys: &ts_keys::NodeState,
1543    name: &str,
1544) -> Result<ts_control::acme::IssuedCert, ts_control::CertError> {
1545    let account_key = match keys.acme_account_key.as_deref() {
1546        Some(der) => ts_control::acme::AcmeAccountKey::from_pkcs8(der)?,
1547        None => {
1548            tracing::debug!(
1549                "no persisted ACME account key in key state; generating an ephemeral per-call key \
1550                 (a new ACME account this issuance — not persisted back)"
1551            );
1552            ts_control::acme::AcmeAccountKey::generate()?.0
1553        }
1554    };
1555    let directory = ts_control::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY
1556        .parse()
1557        .map_err(|e| {
1558            ts_control::CertError::Acme(format!("parsing Let's Encrypt directory URL: {e}"))
1559        })?;
1560    ts_control::issue_cert_pair_via_setdns(config, keys, name, &account_key, &directory).await
1561}
1562
1563/// Publish the cached netmap, if this node has one, onto the netmap bus.
1564///
1565/// The cold-start half of the netmap cache (Go `nodecap.CacheNetworkMaps`). Reads
1566/// [`Config::netmap_cache_dir`](ts_control::Config::netmap_cache_dir) — `None` means the embedder
1567/// configured no storage, so there is nothing to replay — and decodes whatever is there with the
1568/// same decoder the live map poll uses.
1569///
1570/// The read is deliberately **not** gated on the node attributes. Nothing is ever written without
1571/// the grant, so the presence of a cache is itself the record that control asked for one (Go makes
1572/// the same argument: at this point in start-up the client has not spoken to control yet, so the
1573/// grant is not knowable). If the grant has since been withdrawn, the first netmap of this session
1574/// says so and the cache is discarded then.
1575///
1576/// **Peers cached under Tailnet Lock are filtered, not withheld.** Go replays its cached map through
1577/// `tkaFilterNetmapLocked`, which it can do at cold start because its TKA authority is persisted on
1578/// disk; the peers that hold a valid signature survive and are dialed. This port persists the same
1579/// authority next to the cached netmap (see [`load_cached_netmap`]) and runs the same filter over the
1580/// cached peers. With no persisted authority to run it with — a cache written before this node ever
1581/// completed a TKA sync — the peers are withheld and control's first netmap brings them back moments
1582/// later, behind a synced authority.
1583///
1584/// The bus has no replay, so this reaches only subscribers already registered. Every netmap
1585/// subscriber is spawned by `Runtime::spawn` before the control runner and registers from its own
1586/// `on_start` with no I/O in the way, while this path awaits a file read first — so in practice the
1587/// subscribers are there. A subscriber that is not simply misses the head start and is brought
1588/// current by control's first netmap, which is exactly the behaviour of a node with no cache.
1589async fn replay_cached_netmap(params: &Params) {
1590    let Some(dir) = params.config.netmap_cache_dir.as_ref() else {
1591        return;
1592    };
1593
1594    let Some(update) = load_cached_netmap(&ts_control::NetmapCache::new(dir)).await else {
1595        return;
1596    };
1597
1598    let peers = match update.peer_update.as_ref() {
1599        Some(ts_control::PeerUpdate::Full(peers)) => peers.len(),
1600        _ => 0,
1601    };
1602    tracing::info!(peers, "replaying cached netmap on cold start");
1603
1604    if let Err(e) = params.env.publish(Arc::new(update)).await {
1605        tracing::warn!(error = %e, "publishing the cached netmap");
1606    }
1607}
1608
1609/// Read the cached netmap, vouching for its peers with the Tailnet-Lock authority persisted beside
1610/// it — the whole of the cold-start decision, with no actor state in it so it can be tested directly.
1611///
1612/// A netmap cached while the lock was **off** replays whole; there is nothing to enforce (Go's
1613/// `tkaFilterNetmapLocked` returns early on `b.tka == nil`). A netmap cached while the lock was
1614/// **on** replays exactly the peers
1615/// [`PeerTracker::tka_keep_verdicts`](crate::peer_tracker::PeerTracker::tka_keep_verdicts) admits —
1616/// the same pass the live netmap path runs, so a peer that is dialed from the cache is one the
1617/// authority authorized, and an unsigned peer, a peer whose signature fails, or a peer a newer
1618/// rotation obsoletes is dropped.
1619///
1620/// Three things make the persisted authority safe to enforce with:
1621///
1622/// * it is re-verified from genesis on load ([`crate::tka_sync::authority_from_encoded_chain`]), so a
1623///   blob that is not a signed chain yields no authority;
1624/// * it is read back out of the same directory the netmap is, vetted as private to this user, so a
1625///   local attacker cannot choose which chain we start from any more than they can choose the netmap;
1626/// * its head must equal the head the **cached frame** recorded (`MapResponse.TKAInfo.Head`) — see
1627///   [`vouch_cached_peers`]. Go does not need that check because it has no second file to reconcile,
1628///   only the chonk.
1629pub(crate) async fn load_cached_netmap(cache: &ts_control::NetmapCache) -> Option<StateUpdate> {
1630    let authority = match cache.load_tka_chain().await {
1631        None => None,
1632        Some(blob) => match crate::tka_sync::authority_from_encoded_chain(&blob) {
1633            Ok(authority) => Some(authority),
1634            Err(e) => {
1635                tracing::warn!(
1636                    error = %e,
1637                    "persisted tailnet-lock chain did not verify; replaying the cached netmap \
1638                     without its peers"
1639                );
1640                None
1641            }
1642        },
1643    };
1644
1645    cache
1646        .load_state_update_vouched(|tka, peers| vouch_cached_peers(authority.as_ref(), tka, peers))
1647        .await
1648}
1649
1650/// The peers of a netmap cached under an **active** Tailnet Lock that may be replayed on this cold
1651/// start: those the persisted `authority` authorizes, by exactly the pass a live netmap's peers go
1652/// through (Go's `tkaFilterNetmapLocked`, via
1653/// [`PeerTracker::tka_keep_verdicts`](crate::peer_tracker::PeerTracker::tka_keep_verdicts) — the
1654/// per-peer signature verdict plus the cross-peer rotation filter).
1655///
1656/// Nothing is replayed unless the authority can be held to the netmap:
1657///
1658/// * **no persisted authority** (this node has never completed a TKA sync, or the chain did not
1659///   verify) ⇒ no peers. There is nothing to check a `key_signature` against, and admitting them
1660///   unchecked would dial a peer the lock may have revoked while this node was off.
1661/// * **the chain is not at the head the cached frame recorded** (`MapResponse.TKAInfo.Head`) ⇒ no
1662///   peers. The netmap and the chain are written on separate events, so they can disagree — a crash
1663///   between them, or a lock disabled and re-enabled under a fresh genesis — and a chain that
1664///   describes a different lock says nothing about these peers.
1665///
1666/// Separated from the I/O in [`load_cached_netmap`] so the decision itself is directly testable with
1667/// a real chain-derived [`Authority`](ts_tka::Authority) and real signatures.
1668pub(crate) fn vouch_cached_peers(
1669    authority: Option<&ts_tka::Authority>,
1670    tka: &TkaStatus,
1671    peers: Vec<Node>,
1672) -> Vec<Node> {
1673    let Some(authority) = authority else {
1674        tracing::info!(
1675            "no persisted tailnet-lock authority to verify the cached peers against; withholding \
1676             them until control's first netmap"
1677        );
1678        return Vec::new();
1679    };
1680
1681    if !ts_tka::AumHash::from_base32(&tka.head).is_some_and(|head| authority.head_matches(&head)) {
1682        tracing::warn!(
1683            head = %tka.head,
1684            "persisted tailnet-lock chain is not at the head the cached netmap recorded; \
1685             withholding its peers"
1686        );
1687        return Vec::new();
1688    }
1689
1690    let keep = {
1691        let refs: Vec<&Node> = peers.iter().collect();
1692        crate::peer_tracker::PeerTracker::tka_keep_verdicts(Some(authority), &refs)
1693    };
1694    peers
1695        .into_iter()
1696        .zip(keep)
1697        .filter_map(|(peer, keep)| keep.then_some(peer))
1698        .collect()
1699}
1700
1701/// The Tailnet-Lock chain persisted beside the cached netmap ([`load_cached_netmap`]), and the only
1702/// path that writes or removes it.
1703///
1704/// It is one owner because the two operations have to be ordered **against each other**. They used
1705/// not to be: the write was awaited on the actor thread while the removal was `tokio::spawn`ed, so a
1706/// removal scheduled by a lock *disable* could still be sitting in the runtime's queue when a sync
1707/// that followed it persisted a fresh chain, and then delete that fresh chain. Nothing tells the node
1708/// afterwards — the loss shows up one restart later, as a cold start that withholds peers it was
1709/// entitled to dial. Every operation here is awaited by its caller instead, and both callers
1710/// ([`ControlRunner::persist_tka_chain`] and the two lock-off paths) run on the actor thread, so the
1711/// actor's mailbox is the order.
1712///
1713/// This owns no policy about *which* chain may be replayed: the cold-start replay judges whatever it
1714/// finds on disk against the head the cached netmap frame itself recorded, so nothing here can admit
1715/// a peer, and a failed removal is a chain the replay refuses rather than one it trusts.
1716struct PersistedTkaChain {
1717    /// The cache directory the chain lives in, or `None` when the embedder configured no
1718    /// [`netmap_cache_dir`](ts_control::Config::netmap_cache_dir) — then no netmap is cached either,
1719    /// so there is nothing to vouch for and nothing to remove.
1720    cache: Option<ts_control::NetmapCache>,
1721    /// Whether a chain may be on disk right now.
1722    ///
1723    /// **Starts `true`.** A fresh process has synced nothing, but an earlier process of this node can
1724    /// have left a chain in the cache directory, and that chain is precisely what the next cold start
1725    /// would load. So "this process never synced a lock" is not evidence that there is nothing to
1726    /// remove, and the lock-off paths must not treat it as such.
1727    ///
1728    /// Its only job is to keep the repeated removal free: a tailnet with no lock reports the lock off
1729    /// on *every* netmap, and one `remove_file` per netmap tick is a syscall for nothing. It is never
1730    /// trusted in the other direction — a stale `false` cannot admit a peer, because admitting is the
1731    /// replay's decision and the replay reads the disk.
1732    maybe_on_disk: bool,
1733}
1734
1735impl PersistedTkaChain {
1736    /// The chain owner for a node configured with `dir` as its netmap cache directory.
1737    fn new(dir: Option<&std::path::PathBuf>) -> Self {
1738        Self {
1739            cache: dir.map(ts_control::NetmapCache::new),
1740            maybe_on_disk: true,
1741        }
1742    }
1743
1744    /// Persist `blob` as the chain the cached netmap's peers were admitted under. A write failure is
1745    /// logged inside the cache and swallowed; the flag stays set, so the next lock-off still tries to
1746    /// remove whatever did land.
1747    async fn store(&mut self, blob: &[u8]) {
1748        let Some(cache) = self.cache.as_ref() else {
1749            return;
1750        };
1751        cache.store_tka_chain(blob).await;
1752        self.maybe_on_disk = true;
1753    }
1754
1755    /// Remove the persisted chain. Awaited, so it has happened by the time this returns and cannot
1756    /// overtake a later [`store`](Self::store).
1757    async fn discard(&mut self) {
1758        if !self.maybe_on_disk {
1759            return;
1760        }
1761        let Some(cache) = self.cache.as_ref() else {
1762            // No directory was ever configured, so nothing can be on disk and nothing ever will be.
1763            // Settle the flag rather than re-deciding this on every netmap.
1764            self.maybe_on_disk = false;
1765            return;
1766        };
1767        cache.discard_tka_chain().await;
1768        self.maybe_on_disk = false;
1769    }
1770}
1771
1772impl Message<StreamMessage<Arc<StateUpdate>, (), ()>> for ControlRunner {
1773    type Reply = ();
1774
1775    async fn handle(
1776        &mut self,
1777        msg: StreamMessage<Arc<StateUpdate>, (), ()>,
1778        ctx: &mut Context<Self, Self::Reply>,
1779    ) {
1780        match msg {
1781            StreamMessage::Started(_) => {
1782                tracing::trace!("started listening to state updates");
1783            }
1784
1785            StreamMessage::Next(msg) => {
1786                if let Some(node) = msg.node.as_ref() {
1787                    // Reflect node-key expiry into the device state. Control delivering a self-node
1788                    // whose key is in the past means the node must re-authenticate; the arrival of a
1789                    // fresh (non-expired) self-node confirms we are Running (recovering the state if a
1790                    // prior update had flipped it to Expired/Reauthenticating). On expiry, decide
1791                    // between an automatic re-auth (Go `doLogin`: rotate key + re-register with the
1792                    // stored auth key) and the terminal Expired state via the pure `expiry_action`:
1793                    //   - auth key retained, reauth enabled, and TKA NOT enforcing → Reauthenticate.
1794                    //   - otherwise → Expired (no auth key / reauth disabled / TKA-locked).
1795                    // The TKA gate is a hard safety constraint: rotating on a locked tailnet would
1796                    // install an unsigned key and lock this node out of locked peers (the TKA re-sign
1797                    // is a separate follow-up). Recovery from Reauthenticating is automatic — the next
1798                    // good self-node flips back to Running at this same handler.
1799                    let now_unix = std::time::SystemTime::now()
1800                        .duration_since(std::time::UNIX_EPOCH)
1801                        .map(|d| d.as_secs() as i64)
1802                        .unwrap_or(0);
1803                    let action = expiry_action(
1804                        node.key_expired_at_unix(now_unix),
1805                        self.params.auth_key.is_some(),
1806                        self.params.config.reauth_on_expiry,
1807                        self.tka_authority.borrow().is_some(),
1808                    );
1809
1810                    // Bounded reauth sub-state-machine (circuit breaker), evaluated by the pure
1811                    // `reauth_circuit_step`. The `Reauthenticate` path must settle — a one-shot /
1812                    // already-consumed auth key cannot re-register, so an unbounded reauth would sit in
1813                    // `Reauthenticating` forever. The step takes the `expiry_action` verdict, whether we
1814                    // are ALREADY reauthenticating (i.e. the prior reauth has not recovered), and the
1815                    // current attempt counter, and returns the target state, the new counter, and
1816                    // whether to fire the one-shot reauth (fired ONLY on entry, so the node key rotates
1817                    // at most once per episode — a second rotation would lose the original `OldNodeKey`
1818                    // anchor). At `MAX_REAUTH_ATTEMPTS` it trips to terminal `Expired`.
1819                    //
1820                    // NOTE: we may act (count, and eventually flip to `Expired`) even when the published
1821                    // state does NOT change — a repeated `Reauthenticating` self-node is exactly the
1822                    // "prior reauth didn't recover" signal we must tally, so the counting reads the
1823                    // pre-step state directly here and `send_if_modified`'s `changed` only gates the
1824                    // log/firing below, never the counting.
1825                    let already_reauthing = matches!(
1826                        &*self.params.state_tx.borrow(),
1827                        crate::DeviceState::Reauthenticating
1828                    );
1829                    let step = reauth_circuit_step(action, already_reauthing, self.reauth_attempts);
1830                    self.reauth_attempts = step.attempts;
1831                    if step.next == ReauthState::Expired
1832                        && action == ExpiryAction::Reauthenticate
1833                        && already_reauthing
1834                    {
1835                        tracing::warn!(
1836                            attempts = step.attempts,
1837                            "automatic re-auth did not recover the node after {MAX_REAUTH_ATTEMPTS} \
1838                             attempts (auth key likely one-shot / consumed); falling back to terminal \
1839                             Expired"
1840                        );
1841                    }
1842                    let next = match step.next {
1843                        ReauthState::Running => crate::DeviceState::Running,
1844                        ReauthState::Reauthenticating => crate::DeviceState::Reauthenticating,
1845                        ReauthState::Expired => crate::DeviceState::Expired,
1846                    };
1847
1848                    // `send_if_modified` avoids waking watchers when the state is unchanged (a fresh
1849                    // self-node arrives on every netmap update). Returns whether the state changed.
1850                    let changed = self.params.state_tx.send_if_modified(|s| {
1851                        if *s != next {
1852                            *s = next.clone();
1853                            true
1854                        } else {
1855                            false
1856                        }
1857                    });
1858
1859                    if changed && step.fire_reauth {
1860                        tracing::info!(
1861                            "self node-key expired; starting automatic re-auth (rotate node key + \
1862                             re-register with stored auth key)"
1863                        );
1864                        self.client.reauth().await;
1865                    }
1866
1867                    self.self_node.send_replace(Some(node.clone()));
1868                }
1869
1870                if let Some(policy) = msg.ssh_policy.as_ref() {
1871                    self.ssh_policy.send_replace(Some(policy.clone()));
1872                }
1873
1874                if let Some(tka) = msg.tka.as_ref() {
1875                    self.tka.send_replace(Some(tka.clone()));
1876                    self.maybe_sync_tka(tka, ctx.actor_ref().clone()).await;
1877                }
1878
1879                // Track the cert-domain list from the netmap DNS config (Go `nm.DNS.CertDomains`).
1880                // An update with no DNS config, or one carrying no cert domains, means "none" — Go
1881                // reads an empty slice off an absent config too, so mirror that as an empty `Vec`.
1882                let cert_domains = msg
1883                    .dns_config
1884                    .as_ref()
1885                    .map(|d| d.cert_domains.clone())
1886                    .unwrap_or_default();
1887                self.cert_domains.send_replace(cert_domains);
1888
1889                // Track the full DNS config for `Device::dns_config` (the daemon's `tnet dns status`).
1890                // `None` when control sent no DNS config on this update — distinct from a present but
1891                // empty config (Go `netmap.NetworkMap.DNS`).
1892                self.dns_config.send_replace(msg.dns_config.clone());
1893
1894                // Track the interactive-login URL for `Device::pop_browser_url` /
1895                // `Runtime::watch_ipn_bus`. See `sticky_update_pop_browser_url` for the Go-faithful
1896                // sticky semantics (update only on a new non-empty URL; never reset to `None`).
1897                sticky_update_pop_browser_url(&self.pop_browser_url, msg.pop_browser_url.as_ref());
1898
1899                if let Err(e) = self.params.env.publish(msg).await {
1900                    tracing::error!(error = %e, "publishing netmap update");
1901                }
1902            }
1903
1904            StreamMessage::Finished(_) => {
1905                tracing::error!("state update stream terminated")
1906            }
1907        }
1908    }
1909}
1910
1911/// The outcome of a spawned TKA bootstrap+sync task, delivered back to the actor thread so the
1912/// result can be applied to actor state (which a spawned task cannot touch directly). Sent by
1913/// [`ControlRunner::maybe_sync_tka`]; handled by applying via
1914/// [`ControlRunner::apply_tka_synced`](ControlRunner).
1915#[doc(hidden)]
1916pub struct TkaSynced {
1917    pub(crate) result:
1918        Result<Option<crate::tka_sync::SyncedTka>, crate::tka_sync::TkaSyncDriverError>,
1919    /// The [`ControlRunner::tka_generation`] captured when this sync was spawned; the handler
1920    /// discards the result if it no longer matches (the lock was disabled/re-synced mid-flight).
1921    pub(crate) generation: u64,
1922}
1923
1924impl Message<TkaSynced> for ControlRunner {
1925    type Reply = ();
1926
1927    async fn handle(&mut self, msg: TkaSynced, _ctx: &mut Context<Self, Self::Reply>) {
1928        self.apply_tka_synced(msg.result, msg.generation).await;
1929    }
1930}
1931
1932impl Message<DerpLatencyMeasurement> for ControlRunner {
1933    type Reply = ();
1934
1935    async fn handle(&mut self, msg: DerpLatencyMeasurement, _ctx: &mut Context<Self, Self::Reply>) {
1936        let measurements = msg.measurement.as_ref().clone();
1937
1938        // Publish the net-report snapshot for `Device::netcheck` (the daemon's `tnet netcheck`) from
1939        // the same measurements, before the home-region short-circuit below — an empty set still
1940        // yields a (default/empty) report rather than a stale one.
1941        self.netcheck
1942            .send_replace(crate::status::NetcheckReport::from_region_results(
1943                &measurements,
1944            ));
1945
1946        if measurements.is_empty() {
1947            tracing::debug!("derp latency measurements empty");
1948            return;
1949        };
1950
1951        // Record this cycle into the rolling history and evict reports older than the smoothing
1952        // window, then compute each region's `bestRecent` (5-min min). `Instant::now()` is the
1953        // arrival stamp; `best_recent` takes it as a param so the decision stays unit-testable.
1954        let now = Instant::now();
1955        self.derp_report_history
1956            .push((now, msg.measurement.clone()));
1957        self.derp_report_history
1958            .retain(|(stamp, _)| now.saturating_duration_since(*stamp) <= DERP_HISTORY_MAX_AGE);
1959        let best_recent = best_recent(&self.derp_report_history, now, DERP_HISTORY_MAX_AGE);
1960
1961        // Apply selection hysteresis (the pure decision lives in `select_home_region` for testability)
1962        // so jitter between near-equal regions does not flap the home relay. Go's asymmetric
1963        // smoothed-best vs raw-old comparison lives in `select_home_region`; here we just resolve the
1964        // chosen id back to its current-cycle latency for the home-region record + control update.
1965        let selected_id = select_home_region(
1966            self.home_region.map(|(id, _)| id),
1967            &measurements,
1968            &best_recent,
1969        )
1970        .expect("non-empty measurements always yield a selection");
1971        // `select_home_region` only ever returns an id drawn from `measurements`, so this lookup
1972        // always succeeds (same invariant the prior impl relied on when it returned the result by
1973        // reference). We record the current-cycle (raw) latency for the chosen region.
1974        let selected_latency = measurements
1975            .iter()
1976            .find(|m| m.id == selected_id)
1977            .expect("the selected region id is always one of the measurements")
1978            .latency;
1979
1980        let iter = measurements.iter().map(|result| {
1981            (
1982                result.latency_map_key.as_str(),
1983                result.latency.as_secs_f64(),
1984            )
1985        });
1986
1987        if self.home_region.map(|(id, _)| id) != Some(selected_id) {
1988            tracing::debug!(selected_region_id = ?selected_id, "updating home region");
1989        }
1990        self.home_region = Some((selected_id, selected_latency));
1991        // Advertise the smoothed home to control AND drive the local DERP relay to the same region
1992        // (Go `report.PreferredDERP` feeds both). `send_replace` wakes the watch on every send (it
1993        // does NOT coalesce same-value writes), so Multiderp's bridge sees a `SetHomeRegion` each
1994        // cycle; the de-dup is one layer down — `home_transition` returns `Unchanged` for a
1995        // re-selection of the current home, so the relay only churns on an actual home change.
1996        self.client.set_home_region(selected_id, iter).await;
1997        self.params.home_region.send_replace(Some(selected_id));
1998    }
1999}
2000
2001/// The cadence on which this node re-measures the derp map — the fork's analog of Go netcheck's
2002/// `fullReportInterval` (also 5 minutes). This fork has no netcheck timer of its own: a re-measure
2003/// is triggered by control pushing a derp map and by a link change
2004/// ([`MeasureNow`](crate::derp_latency::MeasureNow)), so this is the *assumed* cycle length that
2005/// [`DERP_HISTORY_MAX_AGE`] is sized from, not a period anything here sleeps on.
2006const FULL_REPORT_INTERVAL: Duration = Duration::from_secs(5 * 60);
2007
2008/// The window over which the rolling DERP-latency history is retained: it feeds both `best_recent`
2009/// (the home-region smoothing) and [`ControlRunner::recent_region_latency`] (the exit-node
2010/// suggestion's per-region ranking), exactly as Go's one `netcheck.Client.prev` history feeds both
2011/// `bestRecent` and `RecentRegionLatency`.
2012///
2013/// Sized as Go sizes it — `maxAge = fullReportInterval + ReportTimeout` — so the window covers a
2014/// whole re-measure cycle *plus* the time one measurement is allowed to take, and a report cannot
2015/// age out before its successor exists. Go's stronger guarantee ("the window always holds one
2016/// *full* report") does not carry over: `ts_netcheck`'s `complete_threshold` means **every**
2017/// measurement here is partial, so the window holds a union of partial reports rather than one
2018/// complete one — which is precisely why the union is what the suggestion ranks on.
2019const DERP_HISTORY_MAX_AGE: Duration =
2020    FULL_REPORT_INTERVAL.saturating_add(ts_netcheck::REPORT_TIMEOUT);
2021
2022/// Compute each region's `bestRecent` — its **minimum** latency over the reports within
2023/// `max_age` of `now` (Go `addReportHistoryAndSetPreferredDERP`'s `bestRecent` map). Reports older
2024/// than the window are ignored. `now` and `max_age` are parameters (not clock-read) so this is
2025/// deterministically unit-testable. A region absent from every in-window report is absent from the
2026/// result.
2027///
2028/// This is also the body of Go's `netcheck.Client.RecentRegionLatency()` — the same "lowest latency
2029/// seen per region across the retained history" map — which is why
2030/// [`ControlRunner::recent_region_latency`] serves the exit-node suggestion straight out of it
2031/// rather than keeping a second history.
2032fn best_recent(
2033    history: &[(Instant, Arc<Vec<ts_netcheck::RegionResult>>)],
2034    now: Instant,
2035    max_age: Duration,
2036) -> HashMap<ts_derp::RegionId, Duration> {
2037    let mut best: HashMap<ts_derp::RegionId, Duration> = HashMap::new();
2038    for (stamp, report) in history {
2039        // Skip reports outside the window. `saturating_duration_since` guards a `stamp` that is
2040        // somehow after `now` (clock skew): age 0, always in-window.
2041        if now.saturating_duration_since(*stamp) > max_age {
2042            continue;
2043        }
2044        for r in report.iter() {
2045            best.entry(r.id)
2046                .and_modify(|d| {
2047                    if r.latency < *d {
2048                        *d = r.latency;
2049                    }
2050                })
2051                .or_insert(r.latency);
2052        }
2053    }
2054    best
2055}
2056
2057/// Choose the DERP home region id, applying Go's selection hysteresis
2058/// (`netcheck.addReportHistoryAndSetPreferredDERP`). Pure so the decision is unit-testable.
2059///
2060/// `measurements` is the current cycle sorted by latency ascending (so `measurements[0]` is the
2061/// raw-current best). `best_recent` is each region's smoothed (5-min-min) latency. Matching Go's
2062/// **asymmetric** comparison exactly: the new best candidate is chosen by the *smoothed* `best_recent`
2063/// latency (`bestAny`), while the old/home region is compared using its *current-cycle* (raw)
2064/// latency (`oldRegionCurLatency`). Smoothing the best damps oscillation of the best region across
2065/// the switch boundary that the raw-vs-raw comparison (the prior impl) would still flap on.
2066///
2067/// Keeps the `current` home region unless the new best is *meaningfully* lower-latency — switching
2068/// only when BOTH the current region's raw latency exceeds the smoothed-best by at least
2069/// `PREFERRED_DERP_ABSOLUTE_DIFF` (10ms) AND the smoothed-best is at most two-thirds of the current
2070/// region's raw latency (a >~33% improvement). On the first selection (`current` is `None`), when the
2071/// smoothed-best already IS the current region, or when the current region dropped out of the
2072/// measurements, returns the best directly. `None` only if `measurements` is empty.
2073fn select_home_region(
2074    current: Option<ts_derp::RegionId>,
2075    measurements: &[ts_netcheck::RegionResult],
2076    best_recent: &HashMap<ts_derp::RegionId, Duration>,
2077) -> Option<ts_derp::RegionId> {
2078    /// Go `netcheck.preferredDERPAbsoluteDiff`.
2079    const PREFERRED_DERP_ABSOLUTE_DIFF: Duration = Duration::from_millis(10);
2080
2081    // The smoothed latency for a region: its `best_recent` if present, else its current sample (a
2082    // region seen only this cycle has a 1-sample history, so its min == its current latency anyway).
2083    let smoothed = |m: &ts_netcheck::RegionResult| -> Duration {
2084        best_recent.get(&m.id).copied().unwrap_or(m.latency)
2085    };
2086
2087    // Pick the best candidate by SMOOTHED latency (Go `bestAny = min over regions of bestRecent`).
2088    // `measurements` is sorted by raw latency, but smoothing can reorder, so scan for the smoothed
2089    // minimum explicitly rather than trusting `measurements[0]`.
2090    let best = measurements.iter().min_by_key(|m| smoothed(m))?;
2091    let best_any = smoothed(best);
2092
2093    let Some(old_id) = current.filter(|id| *id != best.id) else {
2094        // First selection, or the smoothed-best already is the current home region.
2095        return Some(best.id);
2096    };
2097
2098    // Compare against the old region's CURRENT (raw) latency this cycle, if it is still present —
2099    // Go's `oldRegionCurLatency`, deliberately unsmoothed (the asymmetry).
2100    match measurements.iter().find(|m| m.id == old_id) {
2101        Some(old) => {
2102            // Byte-faithful to Go: `oldRegionCurLatency - bestAny < 10ms || bestAny >
2103            // oldRegionCurLatency/3*2`. `saturating_sub` matches Go's signed subtraction for the
2104            // `< 10ms` test (when `old < best_any` Go is negative → `< 10ms` true; saturating_sub
2105            // floors to 0 → also true). The two-thirds rule uses INTEGER `Duration` division
2106            // `(old/3)*2` — NOT float `* 2.0/3.0`: Go computes the threshold in integer nanoseconds
2107            // (`oldNs/3` truncates), and float arithmetic diverges from it at the exact 2/3 boundary
2108            // with whole-millisecond inputs (e.g. old=36ms, best=24ms: Go's `24ms > 24ms` is false →
2109            // switch, but float `0.024 > 0.0239999997` is true → keep). `Duration / u32` truncates
2110            // nanos exactly like Go and `* u32` is exact, reproducing `oldRegionCurLatency/3*2`.
2111            let keep_old = old.latency.saturating_sub(best_any) < PREFERRED_DERP_ABSOLUTE_DIFF
2112                || best_any > (old.latency / 3) * 2;
2113            Some(if keep_old { old.id } else { best.id })
2114        }
2115        // The current region is no longer reachable this cycle: take the new best.
2116        None => Some(best.id),
2117    }
2118}
2119
2120impl Message<EndpointAdvertisement> for ControlRunner {
2121    type Reply = ();
2122
2123    async fn handle(&mut self, msg: EndpointAdvertisement, _ctx: &mut Context<Self, Self::Reply>) {
2124        let endpoints: Vec<Endpoint> = msg
2125            .endpoints
2126            .iter()
2127            .map(|ep| Endpoint {
2128                endpoint: ep.addr,
2129                ty: match ep.ty {
2130                    SelfEndpointType::Local => EndpointType::Local,
2131                    SelfEndpointType::Stun => EndpointType::Stun,
2132                    SelfEndpointType::Stun4LocalPort => EndpointType::Stun4LocalPort,
2133                },
2134            })
2135            .collect();
2136
2137        tracing::debug!(
2138            n_endpoints = endpoints.len(),
2139            "advertising endpoints to control"
2140        );
2141
2142        self.client.set_endpoints(endpoints).await;
2143    }
2144}
2145
2146/// Re-advertise this node's routable IP prefixes (`Hostinfo.RoutableIPs`) to control — the wire
2147/// half of a runtime [`Runtime::set_advertise_routes`](crate::Runtime::set_advertise_routes). Sent
2148/// as a direct `ask` from the runtime (not over the bus), so the route change reaches the live
2149/// map-poll client. `routes` is the final advertised set the caller wants control to grant.
2150#[derive(Debug)]
2151pub struct SetAdvertiseRoutes {
2152    /// The prefixes to advertise to control (already filtered to the final set).
2153    pub routes: Vec<ipnet::IpNet>,
2154}
2155
2156impl Message<SetAdvertiseRoutes> for ControlRunner {
2157    type Reply = ();
2158
2159    async fn handle(&mut self, msg: SetAdvertiseRoutes, _ctx: &mut Context<Self, Self::Reply>) {
2160        tracing::debug!(n_routes = msg.routes.len(), "advertising routes to control");
2161        self.client.set_routable_ips(msg.routes).await;
2162    }
2163}
2164
2165/// Update this node's `Hostinfo.Hostname` at control — the wire half of a runtime
2166/// [`Runtime::set_hostname`](crate::Runtime::set_hostname). A direct `ask` from the runtime, so the
2167/// change reaches the live map-poll client.
2168#[derive(Debug)]
2169pub struct SetHostname {
2170    /// The new hostname to report to control.
2171    pub hostname: String,
2172}
2173
2174impl Message<SetHostname> for ControlRunner {
2175    type Reply = ();
2176
2177    async fn handle(&mut self, msg: SetHostname, _ctx: &mut Context<Self, Self::Reply>) {
2178        tracing::debug!("updating hostname at control");
2179        self.client.set_hostname(msg.hostname).await;
2180    }
2181}
2182
2183#[cfg(test)]
2184mod reauth_bridge_tests {
2185    use tokio::sync::watch;
2186
2187    use super::bridge_reauth_url_to_state;
2188    use crate::DeviceState;
2189
2190    fn url(s: &str) -> url::Url {
2191        s.parse().unwrap()
2192    }
2193
2194    /// The bridge maps a surfaced re-auth URL onto `DeviceState::NeedsLogin(url)` — the fix's core:
2195    /// a mid-session `MachineNotAuthorized` (forwarded by the control client as `Some(url)`) becomes
2196    /// the "needs login" state the IPN bus turns into `browse_to_url`.
2197    #[test]
2198    fn bridge_maps_auth_url_to_needs_login() {
2199        let u = url("https://login.example/auth");
2200        let (tx, rx) = watch::channel(DeviceState::Running);
2201
2202        bridge_reauth_url_to_state(&tx, Some(&u));
2203
2204        assert_eq!(*rx.borrow(), DeviceState::NeedsLogin(u));
2205    }
2206
2207    /// `None` never drives a transition — the recovery to `Running` is the netmap self-node
2208    /// handler's job, so the bridge ignores a `None` and leaves the state untouched.
2209    #[test]
2210    fn bridge_none_leaves_state_unchanged() {
2211        let (tx, rx) = watch::channel(DeviceState::Running);
2212
2213        bridge_reauth_url_to_state(&tx, None);
2214
2215        assert_eq!(*rx.borrow(), DeviceState::Running);
2216    }
2217
2218    /// Re-surfacing the same URL across retries does not re-fire the watch (`send_if_modified`
2219    /// dedupe against the cell's current value), so a stuck re-auth does not thrash subscribers.
2220    #[test]
2221    fn bridge_same_url_does_not_refire() {
2222        let u = url("https://login.example/auth");
2223        let (tx, mut rx) = watch::channel(DeviceState::Running);
2224
2225        bridge_reauth_url_to_state(&tx, Some(&u)); // first: fires
2226        assert!(rx.has_changed().unwrap(), "first NeedsLogin fires");
2227        rx.mark_unchanged();
2228        bridge_reauth_url_to_state(&tx, Some(&u)); // same URL: deduped
2229        assert!(
2230            !rx.has_changed().unwrap(),
2231            "the same re-auth URL must not re-fire the state watch"
2232        );
2233    }
2234
2235    /// A genuinely different re-auth URL after a prior one fires again (the dedupe tracks changes,
2236    /// it does not pin the first URL forever).
2237    #[test]
2238    fn bridge_new_url_after_prior_fires() {
2239        let a = url("https://login.example/a");
2240        let b = url("https://login.example/b");
2241        let (tx, rx) = watch::channel(DeviceState::Running);
2242
2243        bridge_reauth_url_to_state(&tx, Some(&a));
2244        bridge_reauth_url_to_state(&tx, Some(&b));
2245
2246        assert_eq!(*rx.borrow(), DeviceState::NeedsLogin(b));
2247    }
2248
2249    /// End-to-end of the *clear* contract: after the bridge sets `NeedsLogin`, the netmap self-node
2250    /// path (modeled here as a direct `send_replace(Running)`, the exact transition the
2251    /// `StreamMessage::Next` handler performs on the next good self-node) flips back to `Running`.
2252    /// This pins that the bridge does NOT need a `None`-clear arm — recovery is owned elsewhere.
2253    #[test]
2254    fn running_netmap_clears_needs_login() {
2255        let u = url("https://login.example/auth");
2256        let (tx, rx) = watch::channel(DeviceState::Running);
2257
2258        bridge_reauth_url_to_state(&tx, Some(&u));
2259        assert_eq!(*rx.borrow(), DeviceState::NeedsLogin(u));
2260
2261        // The self-node handler's recovery transition (next good netmap self-node → Running).
2262        tx.send_replace(DeviceState::Running);
2263        assert_eq!(*rx.borrow(), DeviceState::Running);
2264    }
2265
2266    /// Fix 2 — the bridge must NOT clobber an in-flight automatic re-auth. While the cell holds
2267    /// `Reauthenticating`, an auth URL surfaced by the rotated re-register (over the same
2268    /// `auth_url_tx` cell) must leave the state UNTOUCHED: flipping to `NeedsLogin` would surface a
2269    /// misleading `browse_to_url` on a headless node and, by moving the cell off `Reauthenticating`,
2270    /// re-arm a second node-key rotation that loses the original `OldNodeKey` anchor. The auto-reauth
2271    /// path owns the cell until it recovers to `Running` or trips to `Expired`.
2272    #[test]
2273    fn bridge_does_not_clobber_reauthenticating() {
2274        let u = url("https://login.example/auth");
2275        let (tx, rx) = watch::channel(DeviceState::Reauthenticating);
2276
2277        bridge_reauth_url_to_state(&tx, Some(&u));
2278
2279        assert_eq!(
2280            *rx.borrow(),
2281            DeviceState::Reauthenticating,
2282            "a surfaced auth URL must not downgrade an in-flight auto-reauth to NeedsLogin"
2283        );
2284    }
2285
2286    /// The no-clobber guard is scoped to `Reauthenticating` only — it does not change the bridge's
2287    /// behavior in any other state. From `Running` (and likewise `Connecting`/`NeedsLogin`) a
2288    /// surfaced URL still drives `NeedsLogin` as before, so an ordinary interactive re-auth is
2289    /// unaffected.
2290    #[test]
2291    fn bridge_still_sets_needs_login_from_non_reauthenticating() {
2292        let u = url("https://login.example/auth");
2293        for start in [
2294            DeviceState::Running,
2295            DeviceState::Connecting,
2296            DeviceState::Expired,
2297        ] {
2298            let (tx, rx) = watch::channel(start);
2299            bridge_reauth_url_to_state(&tx, Some(&u));
2300            assert_eq!(*rx.borrow(), DeviceState::NeedsLogin(u.clone()));
2301        }
2302    }
2303}
2304
2305#[cfg(test)]
2306mod sticky_pop_browser_url_tests {
2307    use tokio::sync::watch;
2308
2309    use super::sticky_update_pop_browser_url;
2310
2311    fn url(s: &str) -> url::Url {
2312        s.parse().unwrap()
2313    }
2314
2315    /// A non-empty URL publishes to the cell.
2316    #[test]
2317    fn non_empty_url_publishes() {
2318        let (tx, rx) = watch::channel(None);
2319        let u = url("https://login.example/consent");
2320        sticky_update_pop_browser_url(&tx, Some(&u));
2321        assert_eq!(*rx.borrow(), Some(u));
2322    }
2323
2324    /// An absent (`None`) update — the common netmap tick — must NOT reset the cell. This is the
2325    /// regression guard for the thrash bug (a reset-every-tick would coalesce the URL away on the bus).
2326    #[test]
2327    fn absent_update_does_not_reset() {
2328        let u = url("https://login.example/consent");
2329        let (tx, rx) = watch::channel(Some(u.clone()));
2330        // Simulate many empty netmap updates.
2331        for _ in 0..5 {
2332            sticky_update_pop_browser_url(&tx, None);
2333        }
2334        assert_eq!(
2335            *rx.borrow(),
2336            Some(u),
2337            "empty updates must not clear the URL"
2338        );
2339    }
2340
2341    /// The same URL repeated does not re-fire the watch (in-place dedupe via `send_if_modified`), so
2342    /// a subscriber isn't woken spuriously. Proven by the borrow not having been marked changed.
2343    #[test]
2344    fn repeated_same_url_does_not_refire() {
2345        let u = url("https://login.example/consent");
2346        let (tx, mut rx) = watch::channel(None);
2347        sticky_update_pop_browser_url(&tx, Some(&u)); // first: fires
2348        assert!(rx.has_changed().unwrap(), "first non-empty URL fires");
2349        rx.mark_unchanged();
2350        sticky_update_pop_browser_url(&tx, Some(&u)); // same: deduped
2351        assert!(
2352            !rx.has_changed().unwrap(),
2353            "repeating the same URL must not re-fire the watch"
2354        );
2355    }
2356
2357    /// A genuinely new URL after a prior one fires again (sticky but tracks changes).
2358    #[test]
2359    fn new_url_after_prior_fires() {
2360        let a = url("https://login.example/a");
2361        let b = url("https://login.example/b");
2362        let (tx, rx) = watch::channel(None);
2363        sticky_update_pop_browser_url(&tx, Some(&a));
2364        sticky_update_pop_browser_url(&tx, Some(&b));
2365        assert_eq!(*rx.borrow(), Some(b));
2366    }
2367
2368    /// The realistic session sequence: a URL stays sticky through a run of `None` ticks, and a
2369    /// *different* URL after that gap still fires. Chains the legs the other tests cover in isolation
2370    /// (the actual control cadence is "URL, then many empty updates, then maybe a new URL").
2371    #[test]
2372    fn sticky_through_none_gap_then_new_url_fires() {
2373        let a = url("https://login.example/a");
2374        let b = url("https://login.example/b");
2375        let (tx, rx) = watch::channel(None);
2376        sticky_update_pop_browser_url(&tx, Some(&a));
2377        for _ in 0..3 {
2378            sticky_update_pop_browser_url(&tx, None);
2379        }
2380        assert_eq!(*rx.borrow(), Some(a), "stayed sticky through the None gap");
2381        sticky_update_pop_browser_url(&tx, Some(&b));
2382        assert_eq!(
2383            *rx.borrow(),
2384            Some(b),
2385            "a new URL after a None gap still fires"
2386        );
2387    }
2388
2389    /// Returning to a previously-seen URL (A → B → A) re-fires: the dedupe is against the cell's
2390    /// *current* value, not a full history, so A after B is a genuine change.
2391    #[test]
2392    fn returning_to_prior_url_refires() {
2393        let a = url("https://login.example/a");
2394        let b = url("https://login.example/b");
2395        let (tx, mut rx) = watch::channel(None);
2396        sticky_update_pop_browser_url(&tx, Some(&a));
2397        sticky_update_pop_browser_url(&tx, Some(&b));
2398        rx.mark_unchanged();
2399        sticky_update_pop_browser_url(&tx, Some(&a)); // back to A: differs from current (B) → fires
2400        assert!(
2401            rx.has_changed().unwrap(),
2402            "returning to a prior URL re-fires"
2403        );
2404        assert_eq!(*rx.borrow(), Some(a));
2405    }
2406
2407    /// End-to-end de-thrash: feed a realistic netmap cadence (empty, empty, URL, empty, empty)
2408    /// through the producer into a cell, and count the changes a `run_bus`-style subscriber would
2409    /// observe via `changed()`. The whole point of the fix is that exactly ONE change survives the
2410    /// surrounding `None` thrash — the pre-fix code (`send_replace` every tick) would have woken the
2411    /// subscriber on every empty tick and coalesced the URL away. This exercises the producer + the
2412    /// watch-subscribe path together (the two halves the unit tests cover in isolation).
2413    #[tokio::test]
2414    async fn end_to_end_one_change_survives_none_thrash() {
2415        let u = url("https://login.example/consent");
2416        let (tx, mut rx) = watch::channel(None);
2417        // The cadence control actually sends: mostly-empty MapResponses with one carrying the URL.
2418        let cadence = [None, None, Some(&u), None, None];
2419        for incoming in cadence {
2420            sticky_update_pop_browser_url(&tx, incoming);
2421        }
2422        // A subscriber sees exactly one change, and it carries the URL (not a coalesced `None`).
2423        let mut changes = 0;
2424        while rx.has_changed().unwrap() {
2425            let v = rx.borrow_and_update().clone();
2426            changes += 1;
2427            assert_eq!(v, Some(u.clone()), "the surviving change carries the URL");
2428        }
2429        assert_eq!(changes, 1, "exactly one change survives the None thrash");
2430    }
2431}
2432
2433#[cfg(test)]
2434mod home_region_hysteresis_tests {
2435    use core::time::Duration;
2436    use std::{collections::HashMap, sync::Arc, time::Instant};
2437
2438    use ts_derp::RegionId;
2439    use ts_netcheck::RegionResult;
2440
2441    use super::{DERP_HISTORY_MAX_AGE, best_recent, select_home_region};
2442
2443    fn region(id: u32, latency_ms: u64) -> RegionResult {
2444        RegionResult {
2445            latency: Duration::from_millis(latency_ms),
2446            id: RegionId(core::num::NonZeroU32::new(id).unwrap()),
2447            latency_map_key: format!("region-{id}"),
2448            connected_remote: "127.0.0.1:0".parse().unwrap(),
2449        }
2450    }
2451
2452    fn rid(id: u32) -> RegionId {
2453        RegionId(core::num::NonZeroU32::new(id).unwrap())
2454    }
2455
2456    /// Call `select_home_region` with NO smoothing history — `best_recent` empty, so each region's
2457    /// smoothed latency falls back to its current sample, reproducing the original raw-vs-raw
2458    /// hysteresis these tests pin. (The smoothing-specific tests below pass a populated map.)
2459    fn sel(current: Option<RegionId>, m: &[RegionResult]) -> Option<RegionId> {
2460        select_home_region(current, m, &HashMap::new())
2461    }
2462
2463    /// Empty measurements yield no selection.
2464    #[test]
2465    fn empty_measurements_select_none() {
2466        assert!(sel(Some(rid(1)), &[]).is_none());
2467        assert!(sel(None, &[]).is_none());
2468    }
2469
2470    /// First selection (no current home region) takes the best (lowest-latency) region directly.
2471    #[test]
2472    fn first_selection_takes_best() {
2473        let m = [region(1, 20), region(2, 50)];
2474        assert_eq!(sel(None, &m).unwrap(), rid(1));
2475    }
2476
2477    /// Jitter within the 10ms absolute-diff band keeps the current region (no flap). Current=region 2
2478    /// at 25ms; new best=region 1 at 20ms (only 5ms better) -> keep region 2.
2479    #[test]
2480    fn keeps_current_when_within_absolute_diff() {
2481        let m = [region(1, 20), region(2, 25)];
2482        assert_eq!(
2483            sel(Some(rid(2)), &m).unwrap(),
2484            rid(2),
2485            "a 5ms improvement (< 10ms) must not flap the home region"
2486        );
2487    }
2488
2489    /// A meaningful improvement (>10ms AND best <= 2/3 of current) switches. Current=region 2 at
2490    /// 100ms; new best=region 1 at 20ms -> switch to region 1.
2491    #[test]
2492    fn switches_on_meaningful_improvement() {
2493        let m = [region(1, 20), region(2, 100)];
2494        assert_eq!(
2495            sel(Some(rid(2)), &m).unwrap(),
2496            rid(1),
2497            "a large improvement must switch the home region"
2498        );
2499    }
2500
2501    /// The two-thirds rule: even past the 10ms absolute diff, an improvement that does not beat 2/3
2502    /// of the current latency keeps the current region. current=60ms, best=45ms: diff=15ms (>10ms,
2503    /// so the absolute test alone would switch), but 45 > 60*2/3=40, so keep.
2504    #[test]
2505    fn keeps_current_when_two_thirds_rule_not_met() {
2506        let m = [region(1, 45), region(2, 60)];
2507        assert_eq!(
2508            sel(Some(rid(2)), &m).unwrap(),
2509            rid(2),
2510            "best (45ms) is not <= 2/3 of current (40ms), so keep current despite >10ms diff"
2511        );
2512    }
2513
2514    /// When the current home region is no longer present in the measurements, take the new best.
2515    #[test]
2516    fn switches_when_current_region_absent() {
2517        let m = [region(1, 20), region(3, 25)];
2518        assert_eq!(
2519            sel(Some(rid(2)), &m).unwrap(),
2520            rid(1),
2521            "a current region absent from the measurements falls through to the best"
2522        );
2523    }
2524
2525    /// When the best already IS the current home region, it is kept (no spurious change).
2526    #[test]
2527    fn keeps_current_when_it_is_already_best() {
2528        let m = [region(2, 20), region(1, 50)];
2529        assert_eq!(sel(Some(rid(2)), &m).unwrap(), rid(2));
2530    }
2531
2532    /// The exit-node suggestion ranks on the *union* of the retained partial measurements, not on
2533    /// the newest one — this fork's form of upstream's `TestRecentReportsRetainFullNetcheck`.
2534    ///
2535    /// Go retains reports for `fullReportInterval + ReportTimeout` so the history is guaranteed to
2536    /// hold one *full* netcheck. This fork never produces a full one: `complete_threshold` ends
2537    /// every measurement once three regions have answered, so the window instead has to hold enough
2538    /// *partial* reports for their union to cover every region. Both halves are asserted against
2539    /// the production code — `best_recent`, which `ControlRunner::recent_region_latency` serves the
2540    /// suggestion from, and `suggest_exit_node`, the ranking itself.
2541    #[test]
2542    fn partial_reports_union_keeps_every_region_rankable() {
2543        use ts_control::StableNodeId;
2544
2545        use crate::exit_node_suggest::{ExitNodeCandidate, suggest_exit_node};
2546
2547        let now = Instant::now();
2548
2549        // Six measurement cycles, each naming only three regions — what a `complete_threshold: 3`
2550        // measurement produces against a real derp map. The nearby regions 1/2/3 answer nearly every
2551        // cycle; region 9 (far away, 300ms) got in exactly ONE cycle, four minutes ago; region 7 has
2552        // never been measured at all. The 10-minute-old cycle is outside the retention window and
2553        // its absurdly good 5ms sample for region 9 must not survive into the ranking.
2554        let history = vec![
2555            (now - Duration::from_secs(600), Arc::new(vec![region(9, 5)])),
2556            (
2557                now - Duration::from_secs(240),
2558                Arc::new(vec![region(1, 13), region(2, 20), region(9, 300)]),
2559            ),
2560            (
2561                now - Duration::from_secs(180),
2562                Arc::new(vec![region(1, 11), region(2, 21), region(3, 30)]),
2563            ),
2564            (
2565                now - Duration::from_secs(120),
2566                Arc::new(vec![region(1, 14), region(2, 19), region(3, 31)]),
2567            ),
2568            (
2569                now - Duration::from_secs(60),
2570                Arc::new(vec![region(1, 10), region(2, 23), region(3, 29)]),
2571            ),
2572            (
2573                now,
2574                Arc::new(vec![region(1, 12), region(2, 18), region(3, 28)]),
2575            ),
2576        ];
2577
2578        // Half one: every region any in-window cycle reached still has a latency to rank on, at its
2579        // lowest in-window sample. Region 9's stale 5ms is evicted; its 300ms stands.
2580        let recent = best_recent(&history, now, DERP_HISTORY_MAX_AGE);
2581        for (id, want_ms) in [(1, 10), (2, 18), (3, 28), (9, 300)] {
2582            assert_eq!(
2583                recent.get(&rid(id)).copied(),
2584                Some(Duration::from_millis(want_ms)),
2585                "region {id} must still be rankable off the retained history"
2586            );
2587        }
2588        assert!(
2589            !recent.contains_key(&rid(7)),
2590            "a region no in-window cycle ever measured has no latency"
2591        );
2592
2593        fn exit_node(id: u32, derp: u32) -> ExitNodeCandidate {
2594            ExitNodeCandidate {
2595                stable_id: StableNodeId(format!("stable{id}")),
2596                name: format!("peer{id}"),
2597                derp_region: Some(rid(derp)),
2598                online: Some(true),
2599                advertises_exit_route: true,
2600                has_suggest_cap: true,
2601            }
2602        }
2603        // One exit node in the far-but-measured region 9, one in the never-measured region 7.
2604        let candidates = [exit_node(9, 9), exit_node(7, 7)];
2605        let first_node = |nodes: &[ExitNodeCandidate], _: Option<&StableNodeId>| nodes[0].clone();
2606
2607        // Half two: ranked on the history, region 9's four-minute-old 300ms beats region 7's nothing,
2608        // and the uniform region pick is never reached.
2609        let no_random = |_: &[RegionId]| panic!("select_region must not be reached");
2610        let got = suggest_exit_node(Some(1), &recent, &candidates, None, &no_random, &first_node)
2611            .expect("ok")
2612            .expect("some");
2613        assert_eq!(
2614            got.id,
2615            StableNodeId("stable9".to_owned()),
2616            "a region measured four minutes ago must still beat an unmeasured one"
2617        );
2618
2619        // The regression this replaces: ranked on the NEWEST report alone — regions 1/2/3, the only
2620        // ones that cycle answered — neither candidate's region has a latency, so the suggestion
2621        // falls through to the uniform random pick and the unmeasured region 7 can win the coin flip.
2622        let newest: HashMap<RegionId, Duration> = history
2623            .last()
2624            .unwrap()
2625            .1
2626            .iter()
2627            .map(|r| (r.id, r.latency))
2628            .collect();
2629        let pick_seven = |regions: &[RegionId]| {
2630            assert!(regions.contains(&rid(7)) && regions.contains(&rid(9)));
2631            rid(7)
2632        };
2633        let got = suggest_exit_node(
2634            Some(1),
2635            &newest,
2636            &candidates,
2637            None,
2638            &pick_seven,
2639            &first_node,
2640        )
2641        .expect("ok")
2642        .expect("some");
2643        assert_eq!(
2644            got.id,
2645            StableNodeId("stable7".to_owned()),
2646            "ranking on one partial report leaves both candidates unmeasured and picks at random"
2647        );
2648    }
2649
2650    /// `best_recent` is each region's MINIMUM latency over the in-window reports; a report older than
2651    /// `max_age` is excluded.
2652    #[test]
2653    fn best_recent_is_min_over_window_and_evicts_aged() {
2654        let now = Instant::now();
2655        // Two in-window reports for region 1 (50ms then 20ms) → min 20ms; region 2 once at 30ms.
2656        // One aged report (region 1 at 5ms) outside the window must be ignored.
2657        let history = vec![
2658            (
2659                now - Duration::from_secs(10 * 60), // aged out (> 5min)
2660                Arc::new(vec![region(1, 5)]),
2661            ),
2662            (
2663                now - Duration::from_secs(60),
2664                Arc::new(vec![region(1, 50), region(2, 30)]),
2665            ),
2666            (now, Arc::new(vec![region(1, 20)])),
2667        ];
2668        let br = best_recent(&history, now, DERP_HISTORY_MAX_AGE);
2669        assert_eq!(
2670            br.get(&rid(1)).copied(),
2671            Some(Duration::from_millis(20)),
2672            "region 1 min over the window is 20ms (the aged 5ms is excluded)"
2673        );
2674        assert_eq!(br.get(&rid(2)).copied(), Some(Duration::from_millis(30)));
2675    }
2676
2677    /// The asymmetric comparison: the new best is chosen by its SMOOTHED (best_recent) latency while
2678    /// the old region is compared on its RAW current latency. A best region whose CURRENT sample
2679    /// looks much better but whose 5-min MIN is only marginally better must NOT flap the home region
2680    /// — exactly the oscillation the raw-vs-raw comparison would have switched on.
2681    #[test]
2682    fn smoothed_best_damps_oscillation_across_boundary() {
2683        // Current home = region 2, raw 60ms this cycle. Region 1's CURRENT sample is 20ms (a >2/3,
2684        // >10ms improvement → raw-vs-raw would SWITCH), but its 5-min MIN (best_recent) is 50ms
2685        // (it oscillates). Smoothed-best 50ms vs raw-old 60ms: diff 10ms is NOT < 10ms, but
2686        // 50 > 60*2/3=40 → keepOld. So we KEEP region 2, where the raw comparison would have flapped.
2687        let m = [region(1, 20), region(2, 60)];
2688        let mut br = HashMap::new();
2689        br.insert(rid(1), Duration::from_millis(50)); // smoothed best is worse than its raw sample
2690        br.insert(rid(2), Duration::from_millis(60));
2691        assert_eq!(
2692            select_home_region(Some(rid(2)), &m, &br).unwrap(),
2693            rid(2),
2694            "a best region whose 5-min min is only marginally better must not flap the home region"
2695        );
2696
2697        // Sanity: with NO smoothing (raw 20ms best), the same inputs WOULD switch — proving the
2698        // smoothing is what holds it.
2699        assert_eq!(
2700            select_home_region(Some(rid(2)), &m, &HashMap::new()).unwrap(),
2701            rid(1),
2702            "raw-vs-raw (no smoothing) switches on the 20ms-vs-60ms current samples"
2703        );
2704    }
2705
2706    /// Smoothing can reorder which region is "best": `measurements` is sorted by raw latency, but the
2707    /// smoothed minimum may favor a different region. `select_home_region` must pick by smoothed
2708    /// latency, not blindly trust `measurements[0]`.
2709    #[test]
2710    fn smoothed_best_may_differ_from_raw_first() {
2711        // Raw order: region 1 (10ms) is first. But region 2's 5-min min is 5ms while region 1's is
2712        // 40ms (region 1's 10ms was a lucky low sample). Smoothed-best is region 2. First selection.
2713        let m = [region(1, 10), region(2, 12)];
2714        let mut br = HashMap::new();
2715        br.insert(rid(1), Duration::from_millis(40));
2716        br.insert(rid(2), Duration::from_millis(5));
2717        assert_eq!(
2718            select_home_region(None, &m, &br).unwrap(),
2719            rid(2),
2720            "the smoothed-best region wins even when it is not the raw-latency first"
2721        );
2722    }
2723
2724    /// Byte-faithful integer two-thirds boundary (the float-vs-integer divergence): at exactly
2725    /// `best == old * 2/3` (old=36ms, best=24ms), Go's integer `bestAny > old/3*2` = `24ms > 24ms`
2726    /// is FALSE, so it does NOT keep on the 2/3 arm; and `cond_a` `36-24=12ms < 10ms` is also false,
2727    /// so Go SWITCHES. A float `0.024 > 0.036*2.0/3.0 = 0.0239999997` would wrongly KEEP. This test
2728    /// pins the integer math: it must switch to the best.
2729    #[test]
2730    fn two_thirds_boundary_is_integer_not_float() {
2731        let m = [region(1, 24), region(2, 36)];
2732        // No smoothing (raw == smoothed): isolates the 2/3 arithmetic at the exact boundary.
2733        assert_eq!(
2734            sel(Some(rid(2)), &m).unwrap(),
2735            rid(1),
2736            "at best == old*2/3 the integer rule does NOT keep (Go switches); a float rule would keep"
2737        );
2738    }
2739
2740    /// The `cond_a` (absolute-diff) arm via `saturating_sub`: when the old region's RAW current
2741    /// latency is FASTER than the smoothed-best (old=20ms raw, smoothed-best=50ms), `old - best_any`
2742    /// underflows. Go's signed subtraction is negative (`< 10ms` → keepOld); `saturating_sub` floors
2743    /// to 0 (`< 10ms` → keepOld) — same outcome. The old region is kept.
2744    #[test]
2745    fn old_faster_than_smoothed_best_keeps_via_absolute_diff() {
2746        // Current home = region 2, raw 20ms. Region 1 is the raw-best at 15ms but its smoothed min is
2747        // 50ms (it oscillates badly). smoothed-best candidate by min = region 2 (raw 20 == smoothed
2748        // 20, since br[2]=20) vs region 1 smoothed 50 → best is region 2 itself → already-best path.
2749        // To exercise the old<best_any underflow we need best != old: make region 1 the smoothed best
2750        // at 18ms but the OLD region's raw 20ms... use: old=region2 raw 20, best=region1 smoothed 18.
2751        let m = [region(1, 15), region(2, 20)];
2752        let mut br = HashMap::new();
2753        br.insert(rid(1), Duration::from_millis(18)); // smoothed-best = region 1 at 18ms
2754        br.insert(rid(2), Duration::from_millis(25)); // region 2 smoothed worse than its raw 20ms
2755        // best_any = 18ms (region 1). old (region 2) RAW = 20ms. 20 - 18 = 2ms < 10ms → keepOld.
2756        assert_eq!(
2757            select_home_region(Some(rid(2)), &m, &br).unwrap(),
2758            rid(2),
2759            "old raw (20ms) within 10ms of smoothed-best (18ms) keeps via the absolute-diff arm"
2760        );
2761    }
2762}
2763
2764#[cfg(test)]
2765mod expiry_action_tests {
2766    use super::{ExpiryAction, expiry_action};
2767
2768    /// Not expired → `Running`, regardless of the other inputs (the gate fields are only consulted
2769    /// once the key is expired).
2770    #[test]
2771    fn not_expired_is_always_running() {
2772        for has_auth_key in [false, true] {
2773            for reauth_enabled in [false, true] {
2774                for tka_active in [false, true] {
2775                    assert_eq!(
2776                        expiry_action(false, has_auth_key, reauth_enabled, tka_active),
2777                        ExpiryAction::Running,
2778                        "a non-expired key is Running for any gate combination"
2779                    );
2780                }
2781            }
2782        }
2783    }
2784
2785    /// The ONLY input combination that auto-reauths: expired AND auth key retained AND reauth enabled
2786    /// AND TKA not enforcing.
2787    #[test]
2788    fn expired_with_authkey_reauth_enabled_and_no_tka_reauthenticates() {
2789        assert_eq!(
2790            expiry_action(true, true, true, false),
2791            ExpiryAction::Reauthenticate
2792        );
2793    }
2794
2795    /// Every other expired combination falls back to the terminal `Expired` (today's behavior, no
2796    /// regression): no auth key, reauth disabled, or TKA enforcing each independently forces Expired.
2797    #[test]
2798    fn expired_falls_back_to_expired_for_every_other_combination() {
2799        // The full expired-input matrix minus the single Reauthenticate cell above.
2800        for has_auth_key in [false, true] {
2801            for reauth_enabled in [false, true] {
2802                for tka_active in [false, true] {
2803                    let action = expiry_action(true, has_auth_key, reauth_enabled, tka_active);
2804                    if has_auth_key && reauth_enabled && !tka_active {
2805                        // The one Reauthenticate cell, asserted above.
2806                        assert_eq!(action, ExpiryAction::Reauthenticate);
2807                    } else {
2808                        assert_eq!(
2809                            action,
2810                            ExpiryAction::Expired,
2811                            "expired with has_auth_key={has_auth_key}, \
2812                             reauth_enabled={reauth_enabled}, tka_active={tka_active} must be Expired"
2813                        );
2814                    }
2815                }
2816            }
2817        }
2818    }
2819
2820    /// The TKA safety gate in isolation: even with an auth key and reauth enabled, an ACTIVE lock
2821    /// forces `Expired` (never rotate an unsigned key on a locked tailnet). This is the hard
2822    /// constraint from the design — pinned as its own test so a regression that drops the `!tka_active`
2823    /// term is caught explicitly.
2824    #[test]
2825    fn tka_active_forces_expired_even_when_reauth_would_otherwise_fire() {
2826        assert_eq!(
2827            expiry_action(true, true, true, true),
2828            ExpiryAction::Expired,
2829            "an enforcing Tailnet Lock must veto auto-reauth (unsigned-key lockout safety gate)"
2830        );
2831    }
2832
2833    /// No auth key forces `Expired`: there is nothing to non-interactively re-register with, so even
2834    /// with reauth enabled and no lock the node goes terminal (unchanged from today).
2835    #[test]
2836    fn no_auth_key_forces_expired() {
2837        assert_eq!(
2838            expiry_action(true, false, true, false),
2839            ExpiryAction::Expired
2840        );
2841    }
2842
2843    /// The config opt-out: `reauth_on_expiry=false` forces `Expired` even with an auth key and no
2844    /// lock (the conservative posture / historical behavior).
2845    #[test]
2846    fn reauth_disabled_forces_expired() {
2847        assert_eq!(
2848            expiry_action(true, true, false, false),
2849            ExpiryAction::Expired
2850        );
2851    }
2852}
2853
2854#[cfg(test)]
2855mod reauth_circuit_tests {
2856    use super::{ExpiryAction, MAX_REAUTH_ATTEMPTS, ReauthState, reauth_circuit_step};
2857
2858    /// Entering reauth (a `Reauthenticate` verdict while NOT already reauthenticating) fires the
2859    /// one-shot reauth and starts the counter at 1.
2860    #[test]
2861    fn enter_reauth_fires_once_and_starts_counter() {
2862        let step = reauth_circuit_step(ExpiryAction::Reauthenticate, false, 0);
2863        assert_eq!(step.next, ReauthState::Reauthenticating);
2864        assert_eq!(
2865            step.attempts, 1,
2866            "the entering attempt starts the counter at 1"
2867        );
2868        assert!(step.fire_reauth, "entry fires the one-shot Command::Reauth");
2869    }
2870
2871    /// A subsequent expired self-node while ALREADY reauthenticating (prior reauth not recovered)
2872    /// counts up but does NOT re-fire — re-firing would rotate the node key a second time and lose the
2873    /// original `OldNodeKey` anchor.
2874    #[test]
2875    fn still_reauthing_counts_but_does_not_refire() {
2876        let step = reauth_circuit_step(ExpiryAction::Reauthenticate, true, 1);
2877        assert_eq!(step.next, ReauthState::Reauthenticating);
2878        assert_eq!(
2879            step.attempts, 2,
2880            "a non-recovering reauth increments the counter"
2881        );
2882        assert!(
2883            !step.fire_reauth,
2884            "must NOT re-fire reauth while already reauthenticating (no second rotation)"
2885        );
2886    }
2887
2888    /// At `MAX_REAUTH_ATTEMPTS` consecutive non-recovering reauths, the breaker trips to the terminal
2889    /// `Expired` and stops re-arming reauth (the one-shot auth-key case settles instead of looping).
2890    #[test]
2891    fn trips_to_expired_at_cap() {
2892        // One step below the cap still stays reauthenticating.
2893        let below =
2894            reauth_circuit_step(ExpiryAction::Reauthenticate, true, MAX_REAUTH_ATTEMPTS - 2);
2895        assert_eq!(below.next, ReauthState::Reauthenticating);
2896        assert!(!below.fire_reauth);
2897
2898        // The step that reaches the cap flips to terminal Expired and does not fire.
2899        let at_cap =
2900            reauth_circuit_step(ExpiryAction::Reauthenticate, true, MAX_REAUTH_ATTEMPTS - 1);
2901        assert_eq!(at_cap.attempts, MAX_REAUTH_ATTEMPTS);
2902        assert_eq!(
2903            at_cap.next,
2904            ReauthState::Expired,
2905            "at the cap the circuit breaker trips to terminal Expired"
2906        );
2907        assert!(
2908            !at_cap.fire_reauth,
2909            "a tripped breaker never fires another reauth"
2910        );
2911    }
2912
2913    /// A good (non-expired) self-node resets the counter to 0 (the node recovered) — so a later,
2914    /// genuine expiry cycle gets a fresh full budget of attempts, never a stale leftover count.
2915    #[test]
2916    fn running_resets_counter() {
2917        let step = reauth_circuit_step(ExpiryAction::Running, true, MAX_REAUTH_ATTEMPTS);
2918        assert_eq!(step.next, ReauthState::Running);
2919        assert_eq!(
2920            step.attempts, 0,
2921            "recovery to Running resets the attempt counter"
2922        );
2923        assert!(!step.fire_reauth);
2924    }
2925
2926    /// The non-reauth terminal path (`expiry_action` → `Expired`: no auth key / reauth disabled /
2927    /// TKA-locked) reports `Expired` and never fires reauth; the counter is irrelevant (this path
2928    /// never armed the machine), so it resets to 0.
2929    #[test]
2930    fn expired_action_is_terminal_without_firing() {
2931        let step = reauth_circuit_step(ExpiryAction::Expired, false, 0);
2932        assert_eq!(step.next, ReauthState::Expired);
2933        assert!(!step.fire_reauth);
2934        assert_eq!(step.attempts, 0);
2935    }
2936
2937    /// The full episode as the handler drives it, feeding each step's `attempts` into the next: a
2938    /// one-shot auth key that never recovers fires reauth exactly ONCE, counts each repeated expired
2939    /// self-node, and settles on terminal `Expired` at the cap — never an indefinite `Reauthenticating`
2940    /// spell and never a second rotation.
2941    #[test]
2942    fn full_episode_one_shot_key_settles_at_expired_after_one_fire() {
2943        // Step 1: first expired self-node (state was Running → not already reauthing): enter + fire.
2944        let s1 = reauth_circuit_step(ExpiryAction::Reauthenticate, false, 0);
2945        assert_eq!(s1.next, ReauthState::Reauthenticating);
2946        assert!(s1.fire_reauth);
2947        let mut attempts = s1.attempts;
2948        let mut fires = 1; // counted the one fire
2949
2950        // Steps 2..: still expired while reauthenticating — count only, never re-fire — until the cap.
2951        loop {
2952            let s = reauth_circuit_step(ExpiryAction::Reauthenticate, true, attempts);
2953            if s.fire_reauth {
2954                fires += 1;
2955            }
2956            attempts = s.attempts;
2957            if s.next == ReauthState::Expired {
2958                break;
2959            }
2960            assert_eq!(s.next, ReauthState::Reauthenticating);
2961            assert!(attempts < MAX_REAUTH_ATTEMPTS);
2962        }
2963
2964        assert_eq!(attempts, MAX_REAUTH_ATTEMPTS, "settles exactly at the cap");
2965        assert_eq!(
2966            fires, 1,
2967            "reauth (and thus a node-key rotation) fires exactly once across the whole episode"
2968        );
2969    }
2970
2971    /// Recovery then a fresh failing episode: `Reauthenticating → Running` (reset) → a later expiry
2972    /// re-enters and re-fires. Proves the reset gives the next episode a full attempt budget rather
2973    /// than carrying a stale count that would trip the breaker early.
2974    #[test]
2975    fn recovery_then_new_episode_re_fires() {
2976        // Episode 1 entry.
2977        let e1 = reauth_circuit_step(ExpiryAction::Reauthenticate, false, 0);
2978        assert!(e1.fire_reauth);
2979        // A non-recovering step, then recovery to Running resets.
2980        let mid = reauth_circuit_step(ExpiryAction::Reauthenticate, true, e1.attempts);
2981        assert_eq!(mid.attempts, 2);
2982        let recovered = reauth_circuit_step(ExpiryAction::Running, true, mid.attempts);
2983        assert_eq!(recovered.attempts, 0);
2984
2985        // Episode 2: a fresh expiry (state is Running again → not already reauthing) re-enters + fires.
2986        let e2 = reauth_circuit_step(ExpiryAction::Reauthenticate, false, recovered.attempts);
2987        assert_eq!(e2.next, ReauthState::Reauthenticating);
2988        assert_eq!(e2.attempts, 1, "the new episode starts fresh at 1");
2989        assert!(
2990            e2.fire_reauth,
2991            "a new episode after recovery fires reauth again"
2992        );
2993    }
2994}
2995
2996#[cfg(test)]
2997mod self_lockout_tests {
2998    use ts_tka::{AumHash, Authority, State};
2999
3000    use super::{SelfLockVerdict, self_lock_verdict};
3001
3002    fn node_key() -> ts_keys::NodePublicKey {
3003        ts_keys::NodePrivateKey::random().public_key()
3004    }
3005
3006    /// An empty key-signature is the "not signed yet" case: `Unsigned`, never a lockout warning —
3007    /// so a tailnet that simply has not signed this node does not spam a `warn`.
3008    #[test]
3009    fn empty_signature_is_unsigned_not_locked_out() {
3010        let authority = Authority::from_state(AumHash([0; 32]), State::default());
3011        assert_eq!(
3012            self_lock_verdict(&node_key(), &[], &authority),
3013            SelfLockVerdict::Unsigned
3014        );
3015    }
3016
3017    /// A non-empty key-signature that does not authorize self classifies as `LockedOut` — the
3018    /// operator-facing condition — and the verdict carries the verify error string for the log. Here
3019    /// the blob is non-empty (so we attempt verification rather than short-circuiting to `Unsigned`)
3020    /// but is not a valid NodeKeySignature CBOR (`0x01` decodes as a bare uint with trailing bytes),
3021    /// so `node_key_authorized` returns a `Decode` error → `LockedOut`. The cryptographic-rejection
3022    /// arms (`UntrustedKey` / `BadSignature` for a well-formed-but-untrusted NKS) are covered by
3023    /// `ts_tka`'s own `node_key_authorized` tests; this only needs to prove the runtime classifier
3024    /// routes a verify `Err` to `LockedOut`.
3025    #[test]
3026    fn unverifiable_signature_is_locked_out() {
3027        let authority = Authority::from_state(AumHash([0; 32]), State::default());
3028        let verdict = self_lock_verdict(&node_key(), &[0x01, 0x02, 0x03], &authority);
3029        assert!(
3030            matches!(verdict, SelfLockVerdict::LockedOut(_)),
3031            "a signature the lock cannot authorize must classify as LockedOut, got {verdict:?}"
3032        );
3033    }
3034}
3035
3036#[cfg(test)]
3037mod cached_replay_tests {
3038    //! The cold-start replay of a netmap cached under Tailnet Lock: which of its peers this node
3039    //! dials before control has answered.
3040    //!
3041    //! Go replays its cached map through `setNetMapLocked`, so `tkaFilterNetmapLocked`
3042    //! (`ipn/ipnlocal/tailnet-lock.go`, upstream `9ea7cba44591e0cd840c6c94d23274dd222059bf`) runs
3043    //! over it against the authority it re-opened from disk, and the peers holding a valid signature
3044    //! survive. These cover the same outcome here, over a chain persisted beside the netmap.
3045
3046    use ed25519_dalek::SigningKey;
3047    use ts_tka::{Aum, AumHash, AumKey, Authority, KeyKind, MemAumStore, NodeKeySignature};
3048
3049    use super::{TkaStatus, vouch_cached_peers};
3050    use crate::peer_tracker::tka_tests::peer_node;
3051
3052    /// The node key of the peer the lock authorizes in these tests.
3053    const SIGNED_PEER_KEY: [u8; 32] = [9u8; 32];
3054    /// The node key of the peer that presents nothing.
3055    const UNSIGNED_PEER_KEY: [u8; 32] = [10u8; 32];
3056
3057    /// A locked tailnet: a genesis checkpoint trusting `signer` (signed by it, so the chain verifies),
3058    /// plus the store and the [`Authority`] a completed sync would hold.
3059    fn locked_tailnet(signer: &SigningKey) -> (MemAumStore, AumHash, Authority) {
3060        let key = AumKey {
3061            kind: KeyKind::Ed25519,
3062            votes: 1,
3063            public: signer.verifying_key().to_bytes().to_vec(),
3064            meta: Vec::new(),
3065        };
3066        let mut genesis = Aum::new_genesis_checkpoint(vec![key], vec![vec![0x11; 32]])
3067            .expect("a well-formed genesis checkpoint");
3068        genesis.sign(signer);
3069        let oldest = genesis.hash();
3070        let store = MemAumStore::from_aums([genesis]);
3071        let authority = crate::tka_sync::authority_from_encoded_chain(
3072            &crate::tka_sync::encode_chain(&store, oldest).expect("encode"),
3073        )
3074        .expect("the chain verifies");
3075        (store, oldest, authority)
3076    }
3077
3078    /// The status control stamped on the netmap that was cached under `authority`.
3079    fn cached_lock(authority: &Authority) -> TkaStatus {
3080        TkaStatus {
3081            head: authority.head().to_base32(),
3082            disabled: false,
3083        }
3084    }
3085
3086    /// The headline: with the authority those peers were cached under, the cold start replays the
3087    /// peer that authority authorizes and drops the one it does not.
3088    ///
3089    /// Without the persisted authority this replays **nothing** — which is what made a locked
3090    /// tailnet lose the cache entirely, while Go kept dialing its authorized peers.
3091    #[test]
3092    fn a_persisted_authority_replays_the_peers_it_authorizes() {
3093        let signer = SigningKey::from_bytes(&[42u8; 32]);
3094        let (_store, _oldest, authority) = locked_tailnet(&signer);
3095
3096        let signed = peer_node(
3097            "signed-peer",
3098            SIGNED_PEER_KEY,
3099            NodeKeySignature::sign_direct(&SIGNED_PEER_KEY, &signer).serialize(),
3100        );
3101        let unsigned = peer_node("unsigned-peer", UNSIGNED_PEER_KEY, Vec::new());
3102
3103        let replayed = vouch_cached_peers(
3104            Some(&authority),
3105            &cached_lock(&authority),
3106            vec![signed, unsigned],
3107        );
3108
3109        assert_eq!(
3110            replayed
3111                .iter()
3112                .map(|p| p.stable_id.0.as_str())
3113                .collect::<Vec<_>>(),
3114            vec!["signed-peer"],
3115            "the peer the lock authorizes is dialed at cold start; the unsigned one is dropped"
3116        );
3117    }
3118
3119    /// No persisted authority (this node has never completed a sync, or the chain did not verify) ⇒
3120    /// no peers. There is nothing to check a signature against, and a peer the lock revoked while
3121    /// this node was off must not be dialed on the strength of a cache entry.
3122    #[test]
3123    fn without_an_authority_no_cached_peer_is_replayed() {
3124        let signer = SigningKey::from_bytes(&[42u8; 32]);
3125        let (_store, _oldest, authority) = locked_tailnet(&signer);
3126        let signed = peer_node(
3127            "signed-peer",
3128            SIGNED_PEER_KEY,
3129            NodeKeySignature::sign_direct(&SIGNED_PEER_KEY, &signer).serialize(),
3130        );
3131
3132        assert!(
3133            vouch_cached_peers(None, &cached_lock(&authority), vec![signed]).is_empty(),
3134            "a peer nothing can vouch for waits for control's first netmap"
3135        );
3136    }
3137
3138    /// A netmap cached on disk (raw `MapResponse` JSON, exactly what `NetmapCache` persists), in a
3139    /// directory private to this user so the cache's own vetting accepts it.
3140    #[cfg(unix)]
3141    fn cache_dir_with(label: &str, tka_info: &str, peers: &str) -> std::path::PathBuf {
3142        use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
3143
3144        let dir = std::env::temp_dir().join(format!(
3145            "ts-rs-cached-replay-{}-{label}",
3146            std::process::id()
3147        ));
3148        std::fs::remove_dir_all(&dir).ok();
3149        std::fs::create_dir_all(&dir).expect("scratch dir");
3150        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).expect("chmod");
3151
3152        let body = format!(
3153            r#"{{
3154                {tka_info}
3155                "Node": {{
3156                    "ID": 1,
3157                    "StableID": "self-1",
3158                    "Name": "self.example.ts.net.",
3159                    "Addresses": ["100.64.0.1/32"],
3160                    "CapMap": {{"cache-network-maps": null}}
3161                }},
3162                "Peers": [{peers}],
3163                "DERPMap": {{ "Regions": {{ "3": {{
3164                    "RegionID": 3, "RegionCode": "tst", "RegionName": "Test", "Nodes": []
3165                }} }} }}
3166            }}"#
3167        );
3168        let mut file = std::fs::OpenOptions::new()
3169            .write(true)
3170            .create_new(true)
3171            .mode(0o600)
3172            .open(dir.join(ts_control::NETMAP_CACHE_FILE))
3173            .expect("cache file");
3174        std::io::Write::write_all(&mut file, body.as_bytes()).expect("write cached netmap");
3175        dir
3176    }
3177
3178    /// One cached peer, with no `KeySignature` — an unsigned peer, which a lock drops.
3179    #[cfg(unix)]
3180    const UNSIGNED_CACHED_PEER: &str = r#"{
3181        "ID": 2,
3182        "StableID": "peer-2",
3183        "Name": "peer.example.ts.net.",
3184        "Addresses": ["100.64.0.2/32"],
3185        "Endpoints": ["192.0.2.7:41641"],
3186        "HomeDERP": 3
3187    }"#;
3188
3189    /// End to end over the files a cold start actually finds: the persisted chain is read from the
3190    /// cache directory, re-verified, and used to judge the cached peers — an unsigned one is dropped
3191    /// while the rest of the netmap replays.
3192    ///
3193    /// The *admit* side is proven on [`vouch_cached_peers`] directly rather than here: a cached
3194    /// frame's `KeySignature` decodes to the raw bytes of its JSON string, so a real signature (64
3195    /// arbitrary bytes inside a CBOR structure) cannot be written into a JSON fixture at all.
3196    #[cfg(unix)]
3197    #[tokio::test]
3198    async fn a_cold_start_judges_the_cached_peers_against_the_persisted_chain() {
3199        let signer = SigningKey::from_bytes(&[42u8; 32]);
3200        let (store, oldest, authority) = locked_tailnet(&signer);
3201        let dir = cache_dir_with(
3202            "with-chain",
3203            &format!(
3204                r#""TKAInfo": {{ "Head": "{}", "Disabled": false }},"#,
3205                authority.head().to_base32()
3206            ),
3207            UNSIGNED_CACHED_PEER,
3208        );
3209        let cache = ts_control::NetmapCache::new(&dir);
3210        cache
3211            .store_tka_chain(&crate::tka_sync::encode_chain(&store, oldest).expect("encode"))
3212            .await;
3213
3214        let replayed = super::load_cached_netmap(&cache)
3215            .await
3216            .expect("the cached netmap replays");
3217
3218        assert!(
3219            replayed.peer_update.is_none(),
3220            "an unsigned peer is dropped by the same filter the live netmap runs, got {:?}",
3221            replayed.peer_update
3222        );
3223        assert!(
3224            replayed.node.is_some() && replayed.derp.is_some(),
3225            "the rest of the netmap carries no peer identity and still replays"
3226        );
3227
3228        std::fs::remove_dir_all(&dir).ok();
3229    }
3230
3231    /// A netmap cached while the lock was **off** replays whole: there is no authority to consult and
3232    /// nothing to enforce (Go's filter returns early on `b.tka == nil`).
3233    #[cfg(unix)]
3234    #[tokio::test]
3235    async fn a_cold_start_without_a_lock_replays_every_cached_peer() {
3236        let dir = cache_dir_with("no-lock", "", UNSIGNED_CACHED_PEER);
3237
3238        let replayed = super::load_cached_netmap(&ts_control::NetmapCache::new(&dir))
3239            .await
3240            .expect("the cached netmap replays");
3241
3242        assert!(
3243            matches!(replayed.peer_update, Some(ts_control::PeerUpdate::Full(ref p)) if p.len() == 1),
3244            "an unlocked tailnet replays its cached peers untouched: {:?}",
3245            replayed.peer_update
3246        );
3247
3248        std::fs::remove_dir_all(&dir).ok();
3249    }
3250
3251    /// A chain that does not verify is no authority at all, so the cached peers are withheld — the
3252    /// same outcome as never having persisted one. The netmap around them still replays.
3253    #[cfg(unix)]
3254    #[tokio::test]
3255    async fn a_cold_start_withholds_peers_when_the_persisted_chain_does_not_verify() {
3256        let signer = SigningKey::from_bytes(&[42u8; 32]);
3257        let (_store, _oldest, authority) = locked_tailnet(&signer);
3258        let tka_info = format!(
3259            r#""TKAInfo": {{ "Head": "{}", "Disabled": false }},"#,
3260            authority.head().to_base32()
3261        );
3262
3263        // No chain at all.
3264        let dir = cache_dir_with("no-chain", &tka_info, UNSIGNED_CACHED_PEER);
3265        let replayed = super::load_cached_netmap(&ts_control::NetmapCache::new(&dir))
3266            .await
3267            .expect("the cached netmap replays");
3268        assert!(replayed.peer_update.is_none());
3269        assert!(replayed.node.is_some());
3270        std::fs::remove_dir_all(&dir).ok();
3271
3272        // A chain blob that is not a verified chain.
3273        let dir = cache_dir_with("bad-chain", &tka_info, UNSIGNED_CACHED_PEER);
3274        let cache = ts_control::NetmapCache::new(&dir);
3275        cache.store_tka_chain(b"ts-tka-chain-v1\nAQID").await;
3276        let replayed = super::load_cached_netmap(&cache)
3277            .await
3278            .expect("the cached netmap replays");
3279        assert!(replayed.peer_update.is_none());
3280        assert!(replayed.node.is_some());
3281        std::fs::remove_dir_all(&dir).ok();
3282    }
3283
3284    /// A chain that is not at the head the cached frame recorded describes a different lock — a
3285    /// disable-and-re-enable under a fresh genesis, or a crash between writing the netmap and syncing
3286    /// the chain that followed it. It vouches for nothing.
3287    #[test]
3288    fn a_chain_at_another_head_vouches_for_nothing() {
3289        let signer = SigningKey::from_bytes(&[42u8; 32]);
3290        let (_store, _oldest, authority) = locked_tailnet(&signer);
3291        let signed = peer_node(
3292            "signed-peer",
3293            SIGNED_PEER_KEY,
3294            NodeKeySignature::sign_direct(&SIGNED_PEER_KEY, &signer).serialize(),
3295        );
3296
3297        // Another lock's head: valid base32, not ours.
3298        let other = TkaStatus {
3299            head: AumHash([0x5a; 32]).to_base32(),
3300            disabled: false,
3301        };
3302        assert!(vouch_cached_peers(Some(&authority), &other, vec![signed.clone()]).is_empty());
3303
3304        // A head control did not send, or one this node cannot parse, is not a match either.
3305        for head in ["", "not base32"] {
3306            let malformed = TkaStatus {
3307                head: head.to_string(),
3308                disabled: false,
3309            };
3310            assert!(
3311                vouch_cached_peers(Some(&authority), &malformed, vec![signed.clone()]).is_empty(),
3312                "a head that does not parse must not be treated as matching ({head:?})"
3313            );
3314        }
3315    }
3316}
3317
3318#[cfg(test)]
3319mod persisted_tka_chain_tests {
3320    //! The lifecycle of the Tailnet-Lock chain persisted for the cold-start replay: when it is
3321    //! removed, and that a removal can never outlive the write that follows it.
3322
3323    use super::PersistedTkaChain;
3324
3325    /// A cache directory this test owns, not yet created — [`PersistedTkaChain::store`] creates it
3326    /// private to this user, which is the same path the runtime takes.
3327    fn scratch_dir(label: &str) -> std::path::PathBuf {
3328        let dir =
3329            std::env::temp_dir().join(format!("ts-rs-tka-chain-{}-{label}", std::process::id()));
3330        std::fs::remove_dir_all(&dir).ok();
3331        dir
3332    }
3333
3334    /// An opaque chain blob. The cache stores and returns bytes; only the runtime decodes them, and
3335    /// nothing in this module does.
3336    const CHAIN: &[u8] = b"ts-tka-chain-v1\nAQID";
3337
3338    /// A process that has synced nothing still clears a chain an **earlier** process left behind.
3339    ///
3340    /// This is the state a cold start is in: `tka_synced` is `None` because this process has not
3341    /// spoken to control yet, while the chain the last run persisted is sitting in the cache
3342    /// directory — and that chain is exactly the one the *next* cold start would load. Gating the
3343    /// removal on live synced state (as the lock-off paths once did) leaves it there for good on a
3344    /// node whose lock was turned off between runs.
3345    #[tokio::test]
3346    async fn a_chain_an_earlier_process_left_is_cleared_by_one_that_synced_nothing() {
3347        let dir = scratch_dir("stale");
3348        let cache = ts_control::NetmapCache::new(&dir);
3349        cache.store_tka_chain(CHAIN).await;
3350        assert!(
3351            cache.load_tka_chain().await.is_some(),
3352            "the earlier process's chain is on disk"
3353        );
3354
3355        // Freshly constructed, exactly as `on_start` builds it: no sync has happened.
3356        let mut chain = PersistedTkaChain::new(Some(&dir));
3357        chain.discard().await;
3358
3359        assert!(
3360            cache.load_tka_chain().await.is_none(),
3361            "a lock control reports off clears the chain whether or not this process enforced it"
3362        );
3363
3364        std::fs::remove_dir_all(&dir).ok();
3365    }
3366
3367    /// A removal is complete when it returns, so a chain synced *after* it survives.
3368    ///
3369    /// The removal used to be detached (`tokio::spawn`), so a lock disable could leave one queued
3370    /// while a later sync persisted a fresh chain, and the queued removal would then delete it. The
3371    /// yields below are where such a task would get to run.
3372    #[tokio::test]
3373    async fn a_removal_never_outlives_the_chain_synced_after_it() {
3374        let dir = scratch_dir("ordering");
3375        let cache = ts_control::NetmapCache::new(&dir);
3376        let mut chain = PersistedTkaChain::new(Some(&dir));
3377
3378        chain.store(CHAIN).await;
3379        chain.discard().await;
3380        assert!(
3381            cache.load_tka_chain().await.is_none(),
3382            "the removal has happened by the time it returns; nothing is left pending"
3383        );
3384
3385        // The lock comes back on and a sync persists a new chain.
3386        let resynced = b"ts-tka-chain-v1\nBBBB".as_slice();
3387        chain.store(resynced).await;
3388        for _ in 0..8 {
3389            tokio::task::yield_now().await;
3390        }
3391
3392        assert_eq!(
3393            cache.load_tka_chain().await.as_deref(),
3394            Some(resynced),
3395            "the freshly synced chain is what the next cold start finds"
3396        );
3397
3398        std::fs::remove_dir_all(&dir).ok();
3399    }
3400
3401    /// Repeated lock-off netmaps stay cheap: the removal is idempotent and the chain stays gone.
3402    #[tokio::test]
3403    async fn repeating_the_removal_is_a_no_op() {
3404        let dir = scratch_dir("idempotent");
3405        let cache = ts_control::NetmapCache::new(&dir);
3406        let mut chain = PersistedTkaChain::new(Some(&dir));
3407
3408        chain.store(CHAIN).await;
3409        for _ in 0..3 {
3410            chain.discard().await;
3411            assert!(cache.load_tka_chain().await.is_none());
3412        }
3413
3414        std::fs::remove_dir_all(&dir).ok();
3415    }
3416
3417    /// An embedder that configured no netmap cache directory writes nothing and removes nothing —
3418    /// there is no cache, so there is never a chain to vouch with.
3419    #[tokio::test]
3420    async fn no_cache_directory_means_no_chain_at_all() {
3421        let mut chain = PersistedTkaChain::new(None);
3422        chain.store(CHAIN).await;
3423        chain.discard().await;
3424    }
3425}