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