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