ts_runtime/control_runner.rs
1use core::{
2 net::{Ipv4Addr, Ipv6Addr},
3 time::Duration,
4};
5use std::sync::Arc;
6
7use futures::StreamExt;
8use kameo::{
9 actor::{ActorRef, Spawn},
10 message::{Context, StreamMessage},
11 prelude::Message,
12};
13use tokio::sync::watch;
14use ts_control::{
15 AsyncControlClient, Endpoint, EndpointType, Error as ControlError, IdTokenError, LogoutError,
16 Node, SetDnsError, SshPolicy, StateUpdate, TkaStatus, TkaSyncError, tka_disable,
17 tka_init_begin, tka_init_finish, tka_submit_signature,
18};
19use ts_magicsock::SelfEndpointType;
20
21use crate::{
22 derp_latency::{DerpLatencyMeasurement, DerpLatencyMeasurer},
23 direct::EndpointAdvertisement,
24};
25
26/// Actor responsible for maintaining the connection to control.
27///
28/// This actor is responsible for proxying the map response stream onto the message bus.
29pub struct ControlRunner {
30 client: AsyncControlClient,
31 params: Params,
32
33 self_node: watch::Sender<Option<Node>>,
34 /// Latest Tailscale SSH policy pushed by control, or `None` until control sends one. The SSH
35 /// server reads this to authorize incoming connections; absent policy means deny-all.
36 ssh_policy: watch::Sender<Option<SshPolicy>>,
37 /// Latest Tailnet Lock status pushed by control, or `None` until control sends one.
38 tka: watch::Sender<Option<TkaStatus>>,
39 /// The locally-synced Tailnet-Lock state (verified `Authority` + AUM store), or `None` until a
40 /// successful bootstrap+sync. Held here because `ControlRunner` owns the netmap stream that
41 /// triggers resync. Mutated only on the actor thread (the netmap handler spawns the sync RPC and
42 /// the result returns via the [`TkaSynced`] self-message).
43 tka_synced: Option<crate::tka_sync::SyncedTka>,
44 /// The verified TKA [`Authority`](ts_tka::Authority) the peer tracker **enforces** (Go
45 /// `tkaFilterNetmapLocked`). `None` until the first successful sync, and reset to `None` when the
46 /// lock is disabled. This is the SOLE delivery channel to the peer tracker (which holds the
47 /// matching `Receiver` and reads it on every peer upsert): a `watch` cell, not a bus message, so
48 /// the latest value is always readable, never dropped under load, and writes are strictly ordered
49 /// by this actor — a disable (`None`) can never be reordered behind or dropped before a stale
50 /// `Some`. Written only from [`apply_tka_synced`] (enable) and [`maybe_sync_tka`] (disable), both
51 /// on the actor thread. The published `Authority` has always passed `VerifiedAumChain::verify`.
52 tka_authority: watch::Sender<Option<Arc<ts_tka::Authority>>>,
53 /// In-flight guard: `true` while a sync RPC task is running, so a burst of netmap updates does
54 /// not spawn overlapping syncs (Go serializes sync under `b.mu`).
55 tka_syncing: bool,
56 /// Monotonic generation stamped when a disable (or a fresh sync) supersedes any in-flight sync.
57 /// `maybe_sync_tka` bumps this on a disable transition and captures it into each spawned sync;
58 /// [`apply_tka_synced`] discards a sync result whose captured generation is stale, so a lock
59 /// disabled *while a sync was in flight* is never re-enabled by that sync's late `Ok(Some)`
60 /// (the in-flight window the `tka_synced.is_some()` disable guard alone does not cover).
61 tka_generation: u64,
62 /// Latest cert-domain list from control's netmap DNS config (Go `nm.DNS.CertDomains`), or empty
63 /// until control sends a DNS config carrying one. The facade reads this for `Device::cert_domains`.
64 cert_domains: watch::Sender<Vec<String>>,
65 /// Latest full DNS config from control's netmap (Go `netmap.NetworkMap.DNS`), or `None` until
66 /// control sends one. The facade reads this for `Device::dns_config` (the daemon's
67 /// `tnet dns status`). A superset of [`cert_domains`](Self::cert_domains), which is kept as its
68 /// own cell for the narrower TLS-cert use.
69 dns_config: watch::Sender<Option<ts_control::DnsConfig>>,
70 /// Latest interactive-login / consent URL control asked this node to open
71 /// (`MapResponse.PopBrowserURL`), or `None` until control sends one. The facade reads this for
72 /// `Device::pop_browser_url` (a daemon driving a non-authkey login surfaces it to the user), and
73 /// [`Runtime::watch_ipn_bus`](crate::Runtime::watch_ipn_bus) subscribes to it for the bus's
74 /// `browse_to_url` running-node events.
75 ///
76 /// **Sticky, not per-update** (Go `controlclient` `sess.lastPopBrowserURL`): control sends
77 /// `MapResponse.PopBrowserURL` empty on nearly every netmap tick, so this cell is updated ONLY on
78 /// a non-empty URL that differs from its current value (`sticky_update_pop_browser_url`, via
79 /// `send_if_modified` — the cell's own value is the "last URL seen", so no separate mirror is
80 /// needed). It is never reset to `None` by an empty update — matching Go's `direct.go` guard
81 /// `u != "" && u != sess.lastPopBrowserURL`. Updating on every tick would thrash the cell to
82 /// `None` and coalesce the URL away for a `watch` subscriber.
83 pop_browser_url: watch::Sender<Option<url::Url>>,
84 /// Latest network-conditions report (preferred DERP region + per-region latencies), updated each
85 /// time the DERP-latency measurer reports in. The facade reads this for `Device::netcheck` (the
86 /// daemon's `tnet netcheck`). Empty until the first measurement.
87 netcheck: watch::Sender<crate::status::NetcheckReport>,
88 /// The DERP home region currently selected, with the latency measured for it at selection time.
89 /// `None` until the first home region is chosen. Used to apply selection **hysteresis** (Go
90 /// `netcheck.addReportHistoryAndSetPreferredDERP`): the home region is only switched when a new
91 /// region is *meaningfully* lower-latency than the current one, so jitter between near-equal
92 /// regions does not flap the home relay (which would cause repeated reconnects + brief loss).
93 home_region: Option<(ts_derp::RegionId, core::time::Duration)>,
94 /// Background task that bridges the control client's mid-session re-auth URL cell onto
95 /// [`Self::params`]'s device-state cell (sets [`DeviceState::NeedsLogin`] when control returns
96 /// `MachineNotAuthorized` on a live re-register — see [`bridge_reauth_url_to_state`]). Aborted on
97 /// [`Drop`] so it cannot outlive the actor (the [`DataplaneActor`](crate::dataplane) pattern).
98 reauth_bridge: tokio::task::JoinHandle<()>,
99}
100
101impl Drop for ControlRunner {
102 fn drop(&mut self) {
103 // Stop the re-auth bridge so it does not outlive the actor (mirrors `DataplaneActor`).
104 self.reauth_bridge.abort();
105 }
106}
107
108/// Control runner args.
109pub struct Params {
110 /// Control config.
111 pub(crate) config: ts_control::Config,
112
113 /// Auth key (if needed).
114 pub(crate) auth_key: Option<String>,
115
116 /// The [`crate::Env`] for this actor.
117 pub(crate) env: crate::Env,
118
119 /// Sender for the device connection-state cell. Created in [`Runtime::spawn`](crate::Runtime)
120 /// so it outlives the actor's `on_start` (which may publish [`DeviceState::Failed`] and then
121 /// return `Err`, before `Self` exists). The runtime keeps the matching `Receiver` for
122 /// [`watch_state`](crate::Runtime::watch_state) / [`wait_until_running`](crate::Runtime::wait_until_running).
123 pub(crate) state_tx: watch::Sender<crate::DeviceState>,
124
125 /// Sender for the TKA enforcement-authority cell the peer tracker reads (Go
126 /// `tkaFilterNetmapLocked`). Created in [`Runtime::spawn`](crate::Runtime) and threaded into BOTH
127 /// the peer tracker (the `Receiver`) and this runner (the `Sender`), so the runner is the sole
128 /// writer and the tracker reads the latest verified `Authority` on demand. `None` = no lock /
129 /// disabled (admit all).
130 pub(crate) tka_authority: watch::Sender<Option<Arc<ts_tka::Authority>>>,
131}
132
133#[doc(hidden)]
134#[derive(Debug, thiserror::Error)]
135pub enum ControlRunnerError {
136 #[error(transparent)]
137 Control(#[from] ControlError),
138
139 #[error(transparent)]
140 Crate(#[from] crate::Error),
141}
142
143impl kameo::Actor for ControlRunner {
144 type Args = Params;
145 type Error = ControlRunnerError;
146
147 async fn on_start(params: Params, slf: ActorRef<Self>) -> Result<Self, Self::Error> {
148 loop {
149 match AsyncControlClient::check_auth(
150 ¶ms.config,
151 ¶ms.env.keys,
152 params.auth_key.as_deref(),
153 )
154 .await
155 {
156 Ok(()) => break,
157 Err(ControlError::MachineNotAuthorized(u)) => {
158 tracing::info!(auth_url = %u, "please authorize this machine or pass an auth key");
159 // Surface "interactive login required" so a watcher / `wait_until_running` can
160 // tell the user to authorize, instead of seeing an opaque timeout. Registration
161 // keeps retrying (transient), so this is not a terminal `Failed`.
162 params
163 .state_tx
164 .send_replace(crate::DeviceState::NeedsLogin(u.clone()));
165 tokio::time::sleep(Duration::from_secs(5)).await;
166 }
167 Err(e) => {
168 // A hard registration failure (bad/expired/unknown auth key, etc.). Log the
169 // specific reason control gave AND publish it as a typed `Failed` state so
170 // `Device::wait_until_running` returns the actionable reason (tsr-kqj) instead
171 // of the opaque `Internal(Actor)` the caller would otherwise see once the
172 // stopped actor is next asked. Publishing before `return Err` is why the state
173 // sender lives on `Runtime`, not on `Self` (which never gets constructed here).
174 let reason = crate::RegistrationError::from(&e);
175 tracing::error!(error = %e, "registration failed; control runner stopping");
176 params
177 .state_tx
178 .send_replace(crate::DeviceState::Failed(reason));
179 return Err(e.into());
180 }
181 }
182 }
183 // check_auth succeeded, but the node is not "up" until the netmap stream is actually
184 // attached below. Publish `Running` only AFTER `attach_stream` so `wait_until_running` never
185 // resolves `Ok` for a device whose stream connect failed (which would leave a stopped actor
186 // behind). If the connect/subscribe steps fail, publish a transient `Failed` first so the
187 // waiter sees an actionable reason instead of the opaque post-mortem `Internal(Actor)`.
188 // The control client's live map-poll loop publishes a mid-session re-auth URL here (set when
189 // a re-register returns `MachineNotAuthorized` because the node key expired/was revoked). The
190 // runtime owns the receiver; `connect` takes the sender. Created before `connect` so the
191 // sender is in place for the very first poll, and so the receiver outlives `bring_up`.
192 let (auth_url_tx, auth_url_rx) = watch::channel::<Option<url::Url>>(None);
193
194 let bring_up = async {
195 let (client, stream) = AsyncControlClient::connect(
196 ¶ms.config,
197 ¶ms.env.keys,
198 params.auth_key.as_deref(),
199 auth_url_tx,
200 )
201 .await?;
202
203 DerpLatencyMeasurer::spawn_link(&slf, params.env.clone()).await;
204
205 params.env.subscribe::<DerpLatencyMeasurement>(&slf).await?;
206 params.env.subscribe::<EndpointAdvertisement>(&slf).await?;
207 slf.attach_stream(stream.boxed(), (), ());
208 Ok::<_, ControlRunnerError>(client)
209 };
210
211 let client = match bring_up.await {
212 Ok(client) => client,
213 Err(e) => {
214 tracing::error!(error = %e, "bringing up the control session failed");
215 // The control session never came up; surface it as a transient registration
216 // failure (a retry / fresh `Device::new` may succeed) rather than leaving the state
217 // stuck at `Connecting`.
218 params.state_tx.send_replace(crate::DeviceState::Failed(
219 crate::RegistrationError::NetworkUnreachable,
220 ));
221 return Err(e);
222 }
223 };
224
225 // The netmap stream is attached: the node is up. The stream `Next` handler keeps this
226 // current (and flips to `Expired` if the self-node's key lapses).
227 params.state_tx.send_replace(crate::DeviceState::Running);
228
229 // Bridge the control client's mid-session re-auth URL cell onto the device-state cell: a
230 // `Some(url)` (control returned `MachineNotAuthorized` on a live re-register) becomes
231 // `DeviceState::NeedsLogin(url)` so the IPN bus surfaces `browse_to_url` and the embedder can
232 // prompt the user — the live-session analogue of the initial `check_auth` loop above. The
233 // recovery to `Running` is the netmap self-node handler's job (next good self-node), so this
234 // bridge only forwards `Some`. The task ends when the sender drops (the client's `run` task
235 // ended) and is aborted on actor `Drop`, so it cannot leak past the actor.
236 let reauth_bridge = {
237 let state_tx = params.state_tx.clone();
238 let mut auth_url_rx = auth_url_rx;
239 tokio::spawn(async move {
240 while auth_url_rx.changed().await.is_ok() {
241 let url = auth_url_rx.borrow_and_update().clone();
242 bridge_reauth_url_to_state(&state_tx, url.as_ref());
243 }
244 })
245 };
246
247 // Clone the TKA authority publisher before `params` moves into `Self` below. The matching
248 // `Receiver` lives on the peer tracker; this sender is the sole writer (enforce on sync,
249 // clear on disable).
250 let tka_authority = params.tka_authority.clone();
251
252 Ok(Self {
253 client,
254 params,
255 self_node: Default::default(),
256 ssh_policy: Default::default(),
257 tka: Default::default(),
258 tka_synced: None,
259 tka_authority,
260 tka_syncing: false,
261 tka_generation: 0,
262 cert_domains: Default::default(),
263 dns_config: Default::default(),
264 pop_browser_url: Default::default(),
265 netcheck: Default::default(),
266 home_region: None,
267 reauth_bridge,
268 })
269 }
270}
271
272impl ControlRunner {
273 /// Decide whether the latest netmap's Tailnet-Lock status warrants a (re)sync and, if so, spawn
274 /// the bootstrap+sync RPC off the actor thread (so the netmap stream never blocks on a control
275 /// round-trip). The result returns via the [`TkaSynced`] self-message.
276 ///
277 /// Triggers when control reports TKA enabled (`is_enabled`) AND we are not already syncing AND
278 /// either we hold no `Authority` yet (→ bootstrap) or control's head differs from ours (→ catch
279 /// up). When TKA is disabled, clears any synced state (the lock was turned off). Mirrors Go's
280 /// `tkaSyncIfNeeded`: a no-op when our head already matches.
281 fn maybe_sync_tka(&mut self, tka: &TkaStatus, self_ref: ActorRef<Self>) {
282 if !tka.is_enabled() {
283 // Lock disabled (or never enabled): clear enforcement by writing `None` to the authority
284 // cell the peer tracker reads — synchronously, so it can never be reordered behind or
285 // dropped before a stale `Some` (the failure a best-effort broadcast had). Always bump the
286 // generation so ANY sync currently in flight is invalidated: without this, a disable that
287 // races an in-flight sync (whose `take()` already cleared `tka_synced`) would be a no-op
288 // here, and the sync's late `Ok(Some)` would silently re-enable a lock control just turned
289 // off (the in-flight window the `tka_synced.is_some()` guard alone misses). Cheap and
290 // idempotent: clearing an already-`None` cell and bumping the generation are harmless.
291 self.tka_generation = self.tka_generation.wrapping_add(1);
292 if self.tka_synced.is_some() {
293 tracing::info!("TKA lock disabled; clearing enforcement (admitting all peers)");
294 self.tka_synced = None;
295 }
296 self.tka_authority.send_replace(None);
297 return;
298 }
299 if self.tka_syncing {
300 return; // a sync is already in flight; the next netmap will re-trigger if still stale
301 }
302 // Up-to-date check: if we already have an Authority whose head matches control's, nothing to
303 // do. A malformed control head is treated as "different" (we'll attempt a sync, which
304 // fail-closes harmlessly).
305 if let Some(synced) = &self.tka_synced
306 && let Some(control_head) = ts_tka::AumHash::from_base32(&tka.head)
307 && synced.authority.head_matches(&control_head)
308 {
309 return;
310 }
311
312 // Spawn the sync. Move the current synced state out (the driver takes it by value and returns
313 // the advanced state); `tka_synced` stays `None` until the result lands, guarded by
314 // `tka_syncing` so we don't spawn a second concurrent sync. Capture the current generation so
315 // `apply_tka_synced` can discard this result if a disable bumped the generation while the sync
316 // was in flight (H1: don't re-enable a lock that was disabled mid-sync).
317 self.tka_syncing = true;
318 let generation = self.tka_generation;
319 let current = self.tka_synced.take();
320 let config = self.params.config.clone();
321 let keys = self.params.env.keys.clone();
322 tokio::spawn(async move {
323 let result = crate::tka_sync::sync_tka(&config, &keys, current).await;
324 // Hand the outcome back to the actor thread to apply (mutating actor state off-thread is
325 // not allowed). A send failure just means the actor is gone — nothing to do.
326 if let Err(e) = self_ref.tell(TkaSynced { result, generation }).await {
327 tracing::debug!(error = ?e, "TKA sync result not delivered (actor gone)");
328 }
329 });
330 }
331
332 /// Apply the outcome of a spawned [`maybe_sync_tka`] task on the actor thread: store the advanced
333 /// state + publish the `Authority` to the peer tracker's enforcement cell (or, on inert/failed
334 /// sync, leave peers unaffected). Always clears the in-flight guard.
335 ///
336 /// `generation` is the value captured when the sync was spawned. If it no longer matches
337 /// `self.tka_generation`, the lock was disabled (or re-synced) while this sync was in flight, so
338 /// the result is discarded — never re-enabling an authority control has since turned off.
339 async fn apply_tka_synced(
340 &mut self,
341 result: Result<Option<crate::tka_sync::SyncedTka>, crate::tka_sync::TkaSyncDriverError>,
342 generation: u64,
343 ) {
344 self.tka_syncing = false;
345
346 // H1 guard: a disable (or a superseding sync) bumped the generation while this sync ran. Drop
347 // the stale result — `maybe_sync_tka`'s disable branch already cleared enforcement to `None`,
348 // and re-applying this `Some` would re-enforce a lock that is no longer active.
349 if generation != self.tka_generation {
350 tracing::info!(
351 "TKA sync result superseded (lock disabled or re-synced mid-flight); discarding"
352 );
353 return;
354 }
355
356 match result {
357 Ok(Some(synced)) => {
358 tracing::info!(
359 head = %synced.authority.head().to_base32(),
360 "TKA sync succeeded; enforcing verified Authority (Go tkaFilterNetmapLocked)"
361 );
362 // Deliver the verified Authority to the peer tracker's enforcement cell. The tracker
363 // reads it on every peer upsert and drops unauthorized peers. `Some(..)` = enforce; a
364 // `None` is written on disable. `watch` is the sole channel (last-write-wins, never
365 // dropped, ordered by this actor) — no bus, no re-publish-for-replay needed.
366 self.tka_authority
367 .send_replace(Some(synced.authority.clone()));
368 self.tka_synced = Some(synced);
369 }
370 Ok(None) => {
371 // Control has no lock for us (no genesis / disabled). Clear any authority we were
372 // previously enforcing — symmetric with the disable path — so a transition to
373 // "no lock" stops dropping peers. Not an error.
374 if self.tka_synced.is_some() {
375 tracing::info!("TKA sync: control reports no lock; clearing enforcement");
376 self.tka_synced = None;
377 }
378 self.tka_authority.send_replace(None);
379 }
380 Err(e) => {
381 // Transport or verify failure: log and leave the prior authority in place (a failed
382 // sync must not drop enforcement — that would fail OPEN). NEVER errors the netmap.
383 // The next netmap update re-triggers a sync attempt.
384 tracing::warn!(error = %e, "TKA sync failed; keeping prior enforcement state");
385 }
386 }
387 }
388
389 fn with_self_node<F, R>(&self, f: F) -> impl Future<Output = Option<R>> + use<F, R>
390 where
391 F: FnOnce(&Node) -> R,
392 {
393 let mut sub = self.self_node.subscribe();
394 let mut shutdown = self.params.env.shutdown.clone();
395
396 async move {
397 tokio::select! {
398 _ = shutdown.wait_for(|x| *x) => {
399 None
400 },
401 node = sub.wait_for(Option::is_some) => {
402 Some(f(node.ok()?.as_ref()?))
403 },
404 }
405 }
406 }
407}
408
409/// Apply Go's sticky `PopBrowserURL` semantics to the consent-URL `watch` cell.
410///
411/// Control sends `MapResponse.PopBrowserURL` empty on nearly every netmap update, so the cell is
412/// updated ONLY when `incoming` is a non-empty URL that differs from the cell's current value —
413/// Go's `direct.go` guard `u != "" && u != sess.lastPopBrowserURL`. The cell is **never reset to
414/// `None`** by an empty/absent update — the running-node consent URL is sticky for the session.
415/// Updating unconditionally would thrash the cell to `None` on every tick and coalesce the URL away
416/// for a `watch`/bus subscriber.
417///
418/// The dedupe is in-place via [`watch::Sender::send_if_modified`] — the cell's own value is the
419/// "last URL sent" (this sticky path is its only writer), so no separate mirror field is needed and
420/// the watch is woken only on a genuine change (Go's `sess.lastPopBrowserURL` role, for free). This
421/// matches the [`send_if_modified`](watch::Sender::send_if_modified) idiom already used for the
422/// device-state cell in this handler.
423///
424/// Factored out of the netmap-update handler so the (easy-to-regress) sticky logic is unit-testable
425/// against a plain `watch` channel without standing up the actor.
426fn sticky_update_pop_browser_url(
427 cell: &watch::Sender<Option<url::Url>>,
428 incoming: Option<&url::Url>,
429) {
430 if let Some(url) = incoming {
431 cell.send_if_modified(|current| {
432 if current.as_ref() == Some(url) {
433 false
434 } else {
435 *current = Some(url.clone());
436 true
437 }
438 });
439 }
440}
441
442/// Map a mid-session re-auth URL surfaced by the control client onto the device-state cell.
443///
444/// The control client's live map-poll loop publishes an `Option<url::Url>` into a `watch` cell when
445/// a re-register hits `MachineNotAuthorized` (the node key expired/was revoked mid-session — see
446/// [`ts_control::AsyncControlClient::connect`]'s `auth_url_tx`). `ts_control` cannot name
447/// [`DeviceState`] (it must not depend on this crate), so this bridge fn does the translation:
448/// a `Some(url)` sets [`DeviceState::NeedsLogin`]`(url)` so the IPN bus derives `browse_to_url` and
449/// the embedder can prompt the user, exactly like the initial-registration `check_auth` path.
450///
451/// **Only `Some` drives a transition; `None` is ignored here.** The clear back to
452/// [`DeviceState::Running`] is owned by the netmap self-node handler (the next good self-node flips
453/// it — see the `StreamMessage::Next` arm), which is the authoritative "we are up again" signal; an
454/// independent `None`-clear in this bridge could race that and is unnecessary. The
455/// [`send_if_modified`](watch::Sender::send_if_modified) guard fires the watch only on a genuine
456/// state change (it is a no-op when the cell already holds `NeedsLogin(url)` for the same URL), so a
457/// re-auth URL re-surfaced across retries does not thrash the cell — mirroring the device-state
458/// dedupe in the netmap handler.
459///
460/// Factored out so the (regress-prone) map-and-guard is unit-testable against a plain `watch`
461/// channel without standing up the actor (mirrors [`sticky_update_pop_browser_url`]).
462pub(crate) fn bridge_reauth_url_to_state(
463 state_tx: &watch::Sender<crate::DeviceState>,
464 incoming: Option<&url::Url>,
465) {
466 if let Some(url) = incoming {
467 let next = crate::DeviceState::NeedsLogin(url.clone());
468 state_tx.send_if_modified(|current| {
469 if *current == next {
470 false
471 } else {
472 *current = next.clone();
473 true
474 }
475 });
476 }
477}
478
479// The `#[kameo::messages]` macro generates message structs whose fields mirror the method params;
480// those generated fields carry no doc and can't take attributes, so wrap in a module where
481// missing-docs is allowed (same pattern as PeerTracker's `msg_impl`). The generated message structs
482// are re-exported so callers keep referencing them at `control_runner::<Name>`.
483pub use msg_impl::*;
484
485#[allow(missing_docs)]
486mod msg_impl {
487 use kameo::{message::Context, reply::DelegatedReply};
488
489 use super::*;
490
491 #[kameo::messages]
492 impl ControlRunner {
493 /// Fetch the IPv4 address for this tailscale device.
494 #[message(ctx)]
495 pub fn ipv4(
496 &self,
497 ctx: &mut Context<Self, DelegatedReply<Option<Ipv4Addr>>>,
498 ) -> DelegatedReply<Option<Ipv4Addr>> {
499 let (deleg, replier) = ctx.reply_sender();
500
501 if let Some(replier) = replier {
502 let fut = self.with_self_node(|node| node.tailnet_address.ipv4.addr());
503
504 tokio::spawn(async move {
505 let ip = fut.await;
506 replier.send(ip);
507 });
508 }
509
510 deleg
511 }
512
513 /// Fetch the IPv6 address for this tailscale device.
514 #[message(ctx)]
515 pub fn ipv6(
516 &self,
517 ctx: &mut Context<Self, DelegatedReply<Option<Ipv6Addr>>>,
518 ) -> DelegatedReply<Option<Ipv6Addr>> {
519 let (deleg, replier) = ctx.reply_sender();
520
521 if let Some(replier) = replier {
522 let fut = self.with_self_node(|node| node.tailnet_address.ipv6.addr());
523
524 tokio::spawn(async move {
525 let ip = fut.await;
526 replier.send(ip);
527 });
528 }
529
530 deleg
531 }
532
533 /// Fetch the self node for this tailscale device.
534 #[message(ctx)]
535 pub fn self_node(
536 &self,
537 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
538 ) -> DelegatedReply<Option<Node>> {
539 let (deleg, replier) = ctx.reply_sender();
540
541 if let Some(replier) = replier {
542 let node = self.with_self_node(|node| node.clone());
543
544 tokio::spawn(async move {
545 let node = node.await;
546 replier.send(node)
547 });
548 }
549
550 deleg
551 }
552
553 /// Fetch the current Tailscale SSH policy, if control has pushed one.
554 ///
555 /// Returns `None` when control has not sent an SSH policy (the SSH server treats this as
556 /// deny-all — fail-closed). Unlike `self_node` this does not block waiting
557 /// for a value: an absent policy is a legitimate, immediate answer.
558 #[message]
559 pub fn current_ssh_policy(&self) -> Option<SshPolicy> {
560 self.ssh_policy.borrow().clone()
561 }
562
563 /// Fetch the current Tailnet Lock status, if control has pushed one.
564 ///
565 /// Returns `None` when control has sent no `TKAInfo` (tailnet lock not in use / no change seen).
566 #[message]
567 pub fn current_tka_status(&self) -> Option<TkaStatus> {
568 self.tka.borrow().clone()
569 }
570
571 /// Sign `node_key` directly with this node's network-lock key and submit the signature to
572 /// control (Go `tka.sign` for the Direct case → `tkaSubmitSignature`).
573 ///
574 /// Builds a `Direct` [`NodeKeySignature`](ts_tka::NodeKeySignature) via
575 /// [`sign_direct`](ts_tka::NodeKeySignature::sign_direct) over this node's inner ed25519
576 /// network-lock signing key, serializes it (raw CBOR), and POSTs it to `/machine/tka/sign`.
577 /// Mirrors `set_dns`/`get_certificate`: clones the control config + node keys into a spawned
578 /// task (delegated reply, so the round-trip doesn't block the mailbox) over a fresh Noise
579 /// channel.
580 ///
581 /// **Posture: this only *submits* a signature to control — it does NOT mutate the local
582 /// [`Authority`](ts_tka::Authority).** The local trusted-key state advances solely through the
583 /// existing verified-sync path (`sync_tka` → `VerifiedAumChain::verify`); a `tka_sign` success
584 /// is acknowledged to the caller, and the resulting AUM is picked up on the next netmap-driven
585 /// sync. Verify-and-log is unchanged.
586 #[message(ctx)]
587 pub fn tka_sign(
588 &self,
589 ctx: &mut Context<Self, DelegatedReply<Result<(), TkaSyncError>>>,
590 node_key: [u8; 32],
591 ) -> DelegatedReply<Result<(), TkaSyncError>> {
592 let (deleg, replier) = ctx.reply_sender();
593
594 if let Some(replier) = replier {
595 let config = self.params.config.clone();
596 let keys = self.params.env.keys.clone();
597 tokio::spawn(async move {
598 // Sign the node key with our network-lock key, then submit the raw-CBOR NKS.
599 let nks = ts_tka::NodeKeySignature::sign_direct(
600 &node_key,
601 &keys.network_lock_keys.private.signing_key(),
602 );
603 let req = ts_control::TkaSubmitSignatureRequest {
604 // node_key + version are stamped by the RPC client from `keys`.
605 version: Default::default(),
606 node_key: keys.node_keys.public,
607 signature: nks.serialize(),
608 };
609 let result = tka_submit_signature(
610 &config.server_url,
611 &keys,
612 req,
613 config.allow_http_key_fetch,
614 )
615 .await
616 .map(|_response| ());
617 replier.send(result);
618 });
619 }
620
621 deleg
622 }
623
624 /// Disable Tailnet Lock by presenting the disablement secret to control (Go
625 /// `tka.disable` → `/machine/tka/disable`).
626 ///
627 /// Targets the **current** authority head (read from the cached [`TkaStatus`]); the caller
628 /// supplies the `disablement_secret` out of band (it is the operator-held capability that
629 /// authorizes turning the lock off). Mirrors `tka_sign`: clones config + keys into a spawned
630 /// task (delegated reply). Returns [`TkaSyncError::Unsupported`] when there is no known TKA
631 /// head (lock not in use / control hasn't pushed a status), since there is nothing to disable.
632 ///
633 /// **Submit-only, like `tka_sign`:** this POSTs the disablement to control and does NOT mutate
634 /// the local [`Authority`](ts_tka::Authority). Control acts on the disablement; this node
635 /// observes the result through the existing verified-sync path. Verify-and-log unchanged.
636 #[message(ctx)]
637 pub fn tka_disable(
638 &self,
639 ctx: &mut Context<Self, DelegatedReply<Result<(), TkaSyncError>>>,
640 disablement_secret: Vec<u8>,
641 ) -> DelegatedReply<Result<(), TkaSyncError>> {
642 let (deleg, replier) = ctx.reply_sender();
643
644 if let Some(replier) = replier {
645 // Read the current head from the cached status BEFORE the spawn (can't borrow &self
646 // across the await). No head ⇒ no lock to disable ⇒ Unsupported.
647 let head = self.tka.borrow().as_ref().map(|s| s.head.clone());
648 let config = self.params.config.clone();
649 let keys = self.params.env.keys.clone();
650 tokio::spawn(async move {
651 let result = match head {
652 Some(head) => {
653 let req = ts_control::TkaDisableRequest {
654 // node_key + version are stamped by the RPC client from `keys`.
655 version: Default::default(),
656 node_key: keys.node_keys.public,
657 head,
658 disablement_secret,
659 };
660 tka_disable(&config.server_url, &keys, req, config.allow_http_key_fetch)
661 .await
662 .map(|_response| ())
663 }
664 None => Err(TkaSyncError::Unsupported),
665 };
666 replier.send(result);
667 });
668 }
669
670 deleg
671 }
672
673 /// Initialize Tailnet Lock with this node as the sole initial trusted key, gated by
674 /// `disablement_secret` (Go `LocalClient.NetworkLockInit` — the "lock yourself in" case).
675 ///
676 /// Builds + signs a genesis Checkpoint AUM whose only trusted key is this node's network-lock
677 /// public key (votes 1) and whose single DisablementValue is `disablement_value(secret)`, then
678 /// drives the two-phase init: `tka/init/begin` (submit the genesis) → if control needs no
679 /// further node signatures (`NeedSignatures` empty, the case when this node is the only key) →
680 /// `tka/init/finish` carrying the raw `disablement_secret` as `SupportDisablement`. Mirrors
681 /// `tka_sign`/`tka_disable`: cloned config + keys into a spawned task (delegated reply).
682 ///
683 /// If control returns a non-empty `NeedSignatures` (other nodes must be re-signed under the new
684 /// lock — a multi-node tailnet), this returns [`TkaSyncError::Unsupported`]: re-signing each
685 /// listed node (incl. the Rotation-key case) is a larger flow deferred to a fuller
686 /// `tka_init(keys, secrets)` — the single-node lock-init is the shipped subset.
687 ///
688 /// **Submit-only**, like `tka_sign`/`tka_disable`: this creates the lock at control and does
689 /// NOT seed the local [`Authority`](ts_tka::Authority) — the node picks up the new lock through
690 /// the existing verified netmap-sync (control pushes a `TKAInfo`, `maybe_sync_tka` bootstraps
691 /// the genesis through `VerifiedAumChain::verify`). Verify-and-log posture unchanged.
692 #[message(ctx)]
693 pub fn tka_init(
694 &self,
695 ctx: &mut Context<Self, DelegatedReply<Result<(), TkaSyncError>>>,
696 disablement_secret: Vec<u8>,
697 ) -> DelegatedReply<Result<(), TkaSyncError>> {
698 let (deleg, replier) = ctx.reply_sender();
699
700 if let Some(replier) = replier {
701 let config = self.params.config.clone();
702 let keys = self.params.env.keys.clone();
703 tokio::spawn(async move {
704 let result = tka_init_run(&config, &keys, disablement_secret).await;
705 replier.send(result);
706 });
707 }
708
709 deleg
710 }
711
712 /// The cert-eligible DNS names from control's netmap DNS config (Go `nm.DNS.CertDomains`).
713 ///
714 /// Returns an empty `Vec` when control has sent no DNS config, or one carrying no cert
715 /// domains (an empty list is a legitimate, immediate answer — like `current_ssh_policy`, this
716 /// does not block waiting for a value).
717 #[message]
718 pub fn cert_domains(&self) -> Vec<String> {
719 self.cert_domains.borrow().clone()
720 }
721
722 /// The full DNS config from control's netmap (Go `netmap.NetworkMap.DNS`), or `None` when
723 /// control has sent no DNS config yet. An immediate answer (does not block); the facade
724 /// surfaces this for `Device::dns_config` (the daemon's `tnet dns status`).
725 #[message]
726 pub fn dns_config(&self) -> Option<ts_control::DnsConfig> {
727 self.dns_config.borrow().clone()
728 }
729
730 /// The interactive-login / consent URL control last asked this node to open
731 /// (`MapResponse.PopBrowserURL`), or `None` when control has sent none. An immediate answer
732 /// (does not block); the facade surfaces this for `Device::pop_browser_url`.
733 #[message]
734 pub fn pop_browser_url(&self) -> Option<url::Url> {
735 self.pop_browser_url.borrow().clone()
736 }
737
738 /// Subscribe to the interactive-login / consent URL cell (`MapResponse.PopBrowserURL`).
739 ///
740 /// Returns a [`watch::Receiver`] whose value is the latest running-node consent URL, used by
741 /// [`Runtime::watch_ipn_bus`](crate::Runtime::watch_ipn_bus) to surface `browse_to_url`
742 /// events mid-session. The cell is sticky (updated only on a new non-empty URL, never reset
743 /// to `None` by an empty update — see the field docs), so a subscriber is not thrashed and a
744 /// late subscriber sees the current URL. The initial value is `None` until control sends one.
745 #[message(derive(Clone))]
746 pub fn watch_browser_url(&self) -> watch::Receiver<Option<url::Url>> {
747 self.pop_browser_url.subscribe()
748 }
749
750 /// The latest network-conditions report (preferred DERP region + per-region latencies). An
751 /// immediate answer (does not block); empty before the first DERP-latency measurement. The
752 /// facade surfaces this for `Device::netcheck` (the daemon's `tnet netcheck`).
753 #[message]
754 pub fn netcheck(&self) -> crate::status::NetcheckReport {
755 self.netcheck.borrow().clone()
756 }
757
758 /// Request an OIDC ID token from control scoped to `audience` (workload-identity federation).
759 ///
760 /// Opens a fresh Noise channel and POSTs `/machine/id-token`; returns the signed JWT or an
761 /// [`IdTokenError`]. Runs on a spawned task (delegated reply) so the actor mailbox isn't blocked
762 /// for the round-trip.
763 #[message(ctx)]
764 pub fn fetch_id_token(
765 &self,
766 ctx: &mut Context<Self, DelegatedReply<Result<String, IdTokenError>>>,
767 audience: String,
768 ) -> DelegatedReply<Result<String, IdTokenError>> {
769 let (deleg, replier) = ctx.reply_sender();
770
771 if let Some(replier) = replier {
772 let config = self.params.config.clone();
773 let keys = self.params.env.keys.clone();
774 tokio::spawn(async move {
775 let result = ts_control::fetch_id_token(&config, &keys, &audience).await;
776 replier.send(result);
777 });
778 }
779
780 deleg
781 }
782
783 /// Log this node out of the tailnet: deregister it by expiring its current node key.
784 ///
785 /// Mirrors `fetch_id_token`: clones the control config + node keys
786 /// into a spawned task (delegated reply, so the round-trip doesn't block the mailbox) and
787 /// re-POSTs `/machine/register` with a past expiry over a fresh Noise channel. This is a
788 /// control-plane state change only — it does NOT stop this actor or tear down the datapath
789 /// (the caller follows up with the normal runtime shutdown), and it does not touch the
790 /// on-disk node key, so re-registering with the same key is the re-login path.
791 #[message(ctx)]
792 pub fn logout(
793 &self,
794 ctx: &mut Context<Self, DelegatedReply<Result<(), LogoutError>>>,
795 ) -> DelegatedReply<Result<(), LogoutError>> {
796 let (deleg, replier) = ctx.reply_sender();
797
798 if let Some(replier) = replier {
799 let config = self.params.config.clone();
800 let keys = self.params.env.keys.clone();
801 tokio::spawn(async move {
802 let result = ts_control::logout(&config, &keys).await;
803 replier.send(result);
804 });
805 }
806
807 deleg
808 }
809
810 /// Publish a DNS record for this node via control's `/machine/set-dns` (Go
811 /// `LocalClient.SetDNS`).
812 ///
813 /// Mirrors `fetch_id_token`: clones the control config + node keys
814 /// into a spawned task (delegated reply, so the round-trip doesn't block the mailbox) and
815 /// POSTs the record over a fresh Noise channel. Go's `SetDNS` is `TXT`-only (its sole use is
816 /// the ACME DNS-01 `_acme-challenge` record); the record type is fixed to `"TXT"` here to
817 /// match, so the surfaced API takes only `name` + `value`.
818 #[message(ctx)]
819 pub fn set_dns(
820 &self,
821 ctx: &mut Context<Self, DelegatedReply<Result<(), SetDnsError>>>,
822 name: String,
823 value: String,
824 ) -> DelegatedReply<Result<(), SetDnsError>> {
825 let (deleg, replier) = ctx.reply_sender();
826
827 if let Some(replier) = replier {
828 let config = self.params.config.clone();
829 let keys = self.params.env.keys.clone();
830 tokio::spawn(async move {
831 let result = ts_control::set_dns(&config, &keys, &name, "TXT", &value).await;
832 replier.send(result);
833 });
834 }
835
836 deleg
837 }
838 }
839
840 /// The reply type of the [`get_cert_pair`](ControlRunner::get_cert_pair) message: the issued
841 /// `(cert_chain_pem, key_pem)` PEM pair (the `tnet cert` surface) or a [`ts_control::CertError`].
842 /// Aliased so the message's `Context` type stays under clippy's `type_complexity` bar (the
843 /// nested `Result<(String, String), _>` trips it inline).
844 #[cfg(feature = "acme")]
845 pub type CertPairReply = Result<(String, String), ts_control::CertError>;
846
847 // The `acme`-gated cert-issuance message lives in its own `#[kameo::messages]` impl block so the
848 // proc-macro never sees it in a non-`acme` build (a `#[cfg]` *inside* a single messages-impl
849 // block is not honored by the macro's generated dispatch — it would emit a `GetCertificate`
850 // handler calling a `get_certificate` method that the same `#[cfg]` strips). A separate gated
851 // block keeps the default build clean.
852 #[cfg(feature = "acme")]
853 #[kameo::messages]
854 impl ControlRunner {
855 /// Issue a real Let's Encrypt certificate for this node's MagicDNS `name` via the
856 /// client-side ACME DNS-01 engine (`acme` feature).
857 ///
858 /// Mirrors `fetch_id_token`: clones the control config + node keys
859 /// into a spawned task (delegated reply, so the round-trip doesn't block the mailbox), loads
860 /// or generates the ACME account key, and runs issuance against Let's Encrypt production,
861 /// publishing the DNS-01 challenge TXT through the node's `POST /machine/set-dns` RPC.
862 ///
863 /// The account key is loaded from [`ts_keys::NodeState::acme_account_key`] (PKCS#8 DER) when
864 /// present, so the same ACME account persists across renewals; otherwise an ephemeral key is
865 /// generated for this call only (a fresh ACME account each issuance — acceptable for v1; LE
866 /// allows it). Persisting a generated key back into the key file is the embedder's job (no
867 /// write-back path here). SaaS-only: against a self-hosted control plane the set-dns
868 /// publish 501s.
869 #[message(ctx)]
870 pub fn get_certificate(
871 &self,
872 ctx: &mut Context<
873 Self,
874 DelegatedReply<Result<ts_control::tls::CertifiedKey, ts_control::CertError>>,
875 >,
876 name: String,
877 ) -> DelegatedReply<Result<ts_control::tls::CertifiedKey, ts_control::CertError>> {
878 let (deleg, replier) = ctx.reply_sender();
879
880 if let Some(replier) = replier {
881 let config = self.params.config.clone();
882 let keys = self.params.env.keys.clone();
883 tokio::spawn(async move {
884 let result = issue_certificate(&config, &keys, &name).await;
885 replier.send(result);
886 });
887 }
888
889 deleg
890 }
891
892 /// Issue a real Let's Encrypt certificate for this node's MagicDNS `name` and return the
893 /// **PEM pair** — `(cert_chain_pem, key_pem)` — for writing the on-disk `.crt` + `.key`
894 /// (the daemon's `tnet cert`, Go's `LocalClient.CertPair`). `acme` feature.
895 ///
896 /// Identical issuance to [`get_certificate`](Self::get_certificate) (same client-side ACME
897 /// DNS-01 flow, same set-dns publish, same account-key handling), only the *shape* of the
898 /// result differs: this surfaces the raw chain + leaf-key PEMs instead of the opaque
899 /// [`CertifiedKey`](ts_control::tls::CertifiedKey). The leaf **private key** PEM is the
900 /// second tuple element and is NEVER logged — the spawned task sends it straight back to the
901 /// replier. SaaS-only: against a self-hosted control plane the set-dns publish 501s.
902 #[message(ctx)]
903 pub fn get_cert_pair(
904 &self,
905 ctx: &mut Context<Self, DelegatedReply<CertPairReply>>,
906 name: String,
907 ) -> DelegatedReply<CertPairReply> {
908 let (deleg, replier) = ctx.reply_sender();
909
910 if let Some(replier) = replier {
911 let config = self.params.config.clone();
912 let keys = self.params.env.keys.clone();
913 tokio::spawn(async move {
914 let result = issue_cert_pair(&config, &keys, &name).await;
915 replier.send(result);
916 });
917 }
918
919 deleg
920 }
921 }
922}
923
924/// The `tka_init` body (the genesis-build + two-phase init/begin→init/finish choreography),
925/// factored out of the actor handler so it runs in the spawned task. See [`ControlRunner::tka_init`].
926///
927/// "Lock yourself in": the genesis trusts only this node's network-lock key (votes 1) and stores one
928/// DisablementValue = `disablement_value(secret)`. On a non-empty `NeedSignatures` (multi-node
929/// tailnet needing re-signs) it returns [`TkaSyncError::Unsupported`] — the single-node subset.
930async fn tka_init_run(
931 config: &ts_control::Config,
932 keys: &ts_keys::NodeState,
933 disablement_secret: Vec<u8>,
934) -> Result<(), TkaSyncError> {
935 // Build the genesis: this node's NL public key as the sole trusted key, one disablement value.
936 let nl_public = keys.network_lock_keys.public.to_bytes().to_vec();
937 let genesis_key = ts_tka::AumKey {
938 kind: ts_tka::KeyKind::Ed25519,
939 votes: 1,
940 public: nl_public,
941 meta: Vec::new(),
942 };
943 let dvalue = ts_tka::disablement_value(&disablement_secret).to_vec();
944 let mut genesis = ts_tka::Aum::new_genesis_checkpoint(vec![genesis_key], vec![dvalue])
945 // A malformed genesis is a local construction bug, not a transient RPC failure — surface it as a
946 // coarse internal error rather than NetworkError (which would invite a pointless retry).
947 .map_err(|_| TkaSyncError::Internal(ts_control::TkaSyncInternalErrorKind::SerDe))?;
948 genesis.sign(&keys.network_lock_keys.private.signing_key());
949
950 // Phase 1: submit the genesis. node_key + version are stamped by the RPC client from `keys`.
951 let begin_req = ts_control::TkaInitBeginRequest {
952 version: Default::default(),
953 node_key: keys.node_keys.public,
954 genesis_aum: genesis.serialize(),
955 };
956 let begin_resp = tka_init_begin(
957 &config.server_url,
958 keys,
959 begin_req,
960 config.allow_http_key_fetch,
961 )
962 .await?;
963
964 // Single-node case only: control must need no further node signatures. A non-empty
965 // NeedSignatures means other nodes must be re-signed under the new lock — deferred.
966 if !begin_resp.need_signatures.is_empty() {
967 tracing::warn!(
968 need = begin_resp.need_signatures.len(),
969 "tka_init: control requires re-signing other nodes; the multi-node init is not yet \
970 implemented (single-node lock-init only)"
971 );
972 return Err(TkaSyncError::Unsupported);
973 }
974
975 // Phase 2: finish, carrying the raw disablement secret as SupportDisablement (Go sends the raw
976 // secret here; only the genesis stores its Argon2i hash).
977 let finish_req = ts_control::TkaInitFinishRequest {
978 version: Default::default(),
979 node_key: keys.node_keys.public,
980 signatures: std::collections::BTreeMap::new(),
981 support_disablement: disablement_secret,
982 };
983 tka_init_finish(
984 &config.server_url,
985 keys,
986 finish_req,
987 config.allow_http_key_fetch,
988 )
989 .await
990 .map(|_response| ())
991}
992
993/// Load or generate the ACME account key, then issue a cert for `name` via set-dns DNS-01,
994/// returning just the ready-to-serve [`CertifiedKey`](ts_control::tls::CertifiedKey) (the
995/// `get_certificate` / `ListenTLS` path).
996///
997/// Thin wrapper over [`issue_cert_pair`] that drops the PEMs — one issuance, this caller just
998/// doesn't need the on-disk pair. See [`issue_cert_pair`] for the account-key handling.
999#[cfg(feature = "acme")]
1000async fn issue_certificate(
1001 config: &ts_control::Config,
1002 keys: &ts_keys::NodeState,
1003 name: &str,
1004) -> Result<ts_control::tls::CertifiedKey, ts_control::CertError> {
1005 issue_cert_pair_inner(config, keys, name)
1006 .await
1007 .map(|issued| issued.certified)
1008}
1009
1010/// Load or generate the ACME account key, then issue a cert for `name` via set-dns DNS-01,
1011/// returning the **PEM pair** `(cert_chain_pem, key_pem)` for the daemon's on-disk `.crt`/`.key`
1012/// (`tnet cert`, Go `LocalClient.CertPair`).
1013///
1014/// Same single issuance as [`issue_certificate`]; only the result shape differs. The leaf
1015/// **private key** PEM is the second element and is NEVER logged here.
1016#[cfg(feature = "acme")]
1017async fn issue_cert_pair(
1018 config: &ts_control::Config,
1019 keys: &ts_keys::NodeState,
1020 name: &str,
1021) -> Result<(String, String), ts_control::CertError> {
1022 issue_cert_pair_inner(config, keys, name)
1023 .await
1024 .map(|issued| (issued.cert_chain_pem, issued.key_pem))
1025}
1026
1027/// Shared issuance core for [`issue_certificate`] and [`issue_cert_pair`]: load (or generate) the
1028/// ACME account key, target Let's Encrypt production, and run one DNS-01 issuance, returning the
1029/// full [`IssuedCert`](ts_control::acme::IssuedCert) so each caller projects out what it needs (one
1030/// ACME order, two consumers).
1031///
1032/// Reuses the persisted [`ts_keys::NodeState::acme_account_key`] (PKCS#8 DER) when present so the
1033/// same Let's Encrypt account survives renewals; otherwise generates an ephemeral per-call key
1034/// (logged at debug — a new ACME account each issuance, with no write-back). Always targets Let's
1035/// Encrypt production ([`ts_control::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY`]). Never logs the leaf
1036/// private key.
1037#[cfg(feature = "acme")]
1038async fn issue_cert_pair_inner(
1039 config: &ts_control::Config,
1040 keys: &ts_keys::NodeState,
1041 name: &str,
1042) -> Result<ts_control::acme::IssuedCert, ts_control::CertError> {
1043 let account_key = match keys.acme_account_key.as_deref() {
1044 Some(der) => ts_control::acme::AcmeAccountKey::from_pkcs8(der)?,
1045 None => {
1046 tracing::debug!(
1047 "no persisted ACME account key in key state; generating an ephemeral per-call key \
1048 (a new ACME account this issuance — not persisted back)"
1049 );
1050 ts_control::acme::AcmeAccountKey::generate()?.0
1051 }
1052 };
1053 let directory = ts_control::acme::LETS_ENCRYPT_PRODUCTION_DIRECTORY
1054 .parse()
1055 .map_err(|e| {
1056 ts_control::CertError::Acme(format!("parsing Let's Encrypt directory URL: {e}"))
1057 })?;
1058 ts_control::issue_cert_pair_via_setdns(config, keys, name, &account_key, &directory).await
1059}
1060
1061impl Message<StreamMessage<Arc<StateUpdate>, (), ()>> for ControlRunner {
1062 type Reply = ();
1063
1064 async fn handle(
1065 &mut self,
1066 msg: StreamMessage<Arc<StateUpdate>, (), ()>,
1067 ctx: &mut Context<Self, Self::Reply>,
1068 ) {
1069 match msg {
1070 StreamMessage::Started(_) => {
1071 tracing::trace!("started listening to state updates");
1072 }
1073
1074 StreamMessage::Next(msg) => {
1075 if let Some(node) = msg.node.as_ref() {
1076 // Reflect node-key expiry into the device state: control delivering a self-node
1077 // whose key is in the past means the node must re-authenticate. Otherwise the
1078 // arrival of a fresh self-node confirms we are Running (recovers the state if a
1079 // prior update had flipped it to Expired).
1080 let now_unix = std::time::SystemTime::now()
1081 .duration_since(std::time::UNIX_EPOCH)
1082 .map(|d| d.as_secs() as i64)
1083 .unwrap_or(0);
1084 let next = if node.key_expired_at_unix(now_unix) {
1085 crate::DeviceState::Expired
1086 } else {
1087 crate::DeviceState::Running
1088 };
1089 // `send_if_modified` avoids waking watchers when the state is unchanged (a fresh
1090 // self-node arrives on every netmap update).
1091 self.params.state_tx.send_if_modified(|s| {
1092 if *s != next {
1093 *s = next.clone();
1094 true
1095 } else {
1096 false
1097 }
1098 });
1099
1100 self.self_node.send_replace(Some(node.clone()));
1101 }
1102
1103 if let Some(policy) = msg.ssh_policy.as_ref() {
1104 self.ssh_policy.send_replace(Some(policy.clone()));
1105 }
1106
1107 if let Some(tka) = msg.tka.as_ref() {
1108 self.tka.send_replace(Some(tka.clone()));
1109 self.maybe_sync_tka(tka, ctx.actor_ref().clone());
1110 }
1111
1112 // Track the cert-domain list from the netmap DNS config (Go `nm.DNS.CertDomains`).
1113 // An update with no DNS config, or one carrying no cert domains, means "none" — Go
1114 // reads an empty slice off an absent config too, so mirror that as an empty `Vec`.
1115 let cert_domains = msg
1116 .dns_config
1117 .as_ref()
1118 .map(|d| d.cert_domains.clone())
1119 .unwrap_or_default();
1120 self.cert_domains.send_replace(cert_domains);
1121
1122 // Track the full DNS config for `Device::dns_config` (the daemon's `tnet dns status`).
1123 // `None` when control sent no DNS config on this update — distinct from a present but
1124 // empty config (Go `netmap.NetworkMap.DNS`).
1125 self.dns_config.send_replace(msg.dns_config.clone());
1126
1127 // Track the interactive-login URL for `Device::pop_browser_url` /
1128 // `Runtime::watch_ipn_bus`. See `sticky_update_pop_browser_url` for the Go-faithful
1129 // sticky semantics (update only on a new non-empty URL; never reset to `None`).
1130 sticky_update_pop_browser_url(&self.pop_browser_url, msg.pop_browser_url.as_ref());
1131
1132 if let Err(e) = self.params.env.publish(msg).await {
1133 tracing::error!(error = %e, "publishing netmap update");
1134 }
1135 }
1136
1137 StreamMessage::Finished(_) => {
1138 tracing::error!("state update stream terminated")
1139 }
1140 }
1141 }
1142}
1143
1144/// The outcome of a spawned TKA bootstrap+sync task, delivered back to the actor thread so the
1145/// result can be applied to actor state (which a spawned task cannot touch directly). Sent by
1146/// [`ControlRunner::maybe_sync_tka`]; handled by applying via
1147/// [`ControlRunner::apply_tka_synced`](ControlRunner).
1148#[doc(hidden)]
1149pub struct TkaSynced {
1150 pub(crate) result:
1151 Result<Option<crate::tka_sync::SyncedTka>, crate::tka_sync::TkaSyncDriverError>,
1152 /// The [`ControlRunner::tka_generation`] captured when this sync was spawned; the handler
1153 /// discards the result if it no longer matches (the lock was disabled/re-synced mid-flight).
1154 pub(crate) generation: u64,
1155}
1156
1157impl Message<TkaSynced> for ControlRunner {
1158 type Reply = ();
1159
1160 async fn handle(&mut self, msg: TkaSynced, _ctx: &mut Context<Self, Self::Reply>) {
1161 self.apply_tka_synced(msg.result, msg.generation).await;
1162 }
1163}
1164
1165impl Message<DerpLatencyMeasurement> for ControlRunner {
1166 type Reply = ();
1167
1168 async fn handle(&mut self, msg: DerpLatencyMeasurement, _ctx: &mut Context<Self, Self::Reply>) {
1169 let measurements = msg.measurement.as_ref().clone();
1170
1171 // Publish the net-report snapshot for `Device::netcheck` (the daemon's `tnet netcheck`) from
1172 // the same measurements, before the home-region short-circuit below — an empty set still
1173 // yields a (default/empty) report rather than a stale one.
1174 self.netcheck
1175 .send_replace(crate::status::NetcheckReport::from_region_results(
1176 &measurements,
1177 ));
1178
1179 if measurements.is_empty() {
1180 tracing::debug!("derp latency measurements empty");
1181 return;
1182 };
1183
1184 // Apply selection hysteresis (the pure decision lives in `select_home_region` for testability)
1185 // so jitter between near-equal regions does not flap the home relay. Copy the chosen id +
1186 // latency out of the borrowed result so nothing borrows `measurements` across the `.await`.
1187 let (selected_id, selected_latency) = {
1188 let selected = select_home_region(self.home_region.map(|(id, _)| id), &measurements)
1189 .expect("non-empty measurements always yield a selection");
1190 (selected.id, selected.latency)
1191 };
1192
1193 let iter = measurements.iter().map(|result| {
1194 (
1195 result.latency_map_key.as_str(),
1196 result.latency.as_secs_f64(),
1197 )
1198 });
1199
1200 if self.home_region.map(|(id, _)| id) != Some(selected_id) {
1201 tracing::debug!(selected_region_id = ?selected_id, "updating home region");
1202 }
1203 self.home_region = Some((selected_id, selected_latency));
1204 self.client.set_home_region(selected_id, iter).await;
1205 }
1206}
1207
1208/// Choose the DERP home region from `measurements` (expected sorted by latency ascending, so
1209/// `measurements[0]` is the lowest-latency "best"), applying Go's selection hysteresis
1210/// (`netcheck.addReportHistoryAndSetPreferredDERP`). Pure so the decision is unit-testable.
1211///
1212/// Keeps the `current` home region (when it is still present in `measurements`) unless the new best
1213/// is *meaningfully* lower-latency — switching only when BOTH: the current region's fresh latency
1214/// exceeds the best by at least `PREFERRED_DERP_ABSOLUTE_DIFF` (10ms), AND the best is at most
1215/// two-thirds of the current region's latency (a >~33% improvement). This avoids flapping the home
1216/// relay between regions whose latencies jitter within ~10ms. On the first selection (`current` is
1217/// `None`), when the best already IS the current region, or when the current region dropped out of
1218/// the measurements, returns the best directly. `None` only if `measurements` is empty.
1219fn select_home_region(
1220 current: Option<ts_derp::RegionId>,
1221 measurements: &[ts_netcheck::RegionResult],
1222) -> Option<&ts_netcheck::RegionResult> {
1223 /// Go `netcheck.preferredDERPAbsoluteDiff`.
1224 const PREFERRED_DERP_ABSOLUTE_DIFF: core::time::Duration =
1225 core::time::Duration::from_millis(10);
1226
1227 let best = measurements.first()?;
1228
1229 let Some(old_id) = current.filter(|id| *id != best.id) else {
1230 // First selection, or the best already is the current home region.
1231 return Some(best);
1232 };
1233
1234 // Compare against the current region's FRESH latency (not a stale one), if it is still present.
1235 match measurements.iter().find(|m| m.id == old_id) {
1236 Some(old) => {
1237 let keep_old = old.latency.saturating_sub(best.latency) < PREFERRED_DERP_ABSOLUTE_DIFF
1238 || best.latency.as_secs_f64() > old.latency.as_secs_f64() * 2.0 / 3.0;
1239 Some(if keep_old { old } else { best })
1240 }
1241 // The current region is no longer reachable this cycle: take the new best.
1242 None => Some(best),
1243 }
1244}
1245
1246impl Message<EndpointAdvertisement> for ControlRunner {
1247 type Reply = ();
1248
1249 async fn handle(&mut self, msg: EndpointAdvertisement, _ctx: &mut Context<Self, Self::Reply>) {
1250 let endpoints: Vec<Endpoint> = msg
1251 .endpoints
1252 .iter()
1253 .map(|ep| Endpoint {
1254 endpoint: ep.addr,
1255 ty: match ep.ty {
1256 SelfEndpointType::Local => EndpointType::Local,
1257 SelfEndpointType::Stun => EndpointType::Stun,
1258 SelfEndpointType::Stun4LocalPort => EndpointType::Stun4LocalPort,
1259 },
1260 })
1261 .collect();
1262
1263 tracing::debug!(
1264 n_endpoints = endpoints.len(),
1265 "advertising endpoints to control"
1266 );
1267
1268 self.client.set_endpoints(endpoints).await;
1269 }
1270}
1271
1272/// Re-advertise this node's routable IP prefixes (`Hostinfo.RoutableIPs`) to control — the wire
1273/// half of a runtime [`Runtime::set_advertise_routes`](crate::Runtime::set_advertise_routes). Sent
1274/// as a direct `ask` from the runtime (not over the bus), so the route change reaches the live
1275/// map-poll client. `routes` is the final advertised set the caller wants control to grant.
1276#[derive(Debug)]
1277pub struct SetAdvertiseRoutes {
1278 /// The prefixes to advertise to control (already filtered to the final set).
1279 pub routes: Vec<ipnet::IpNet>,
1280}
1281
1282impl Message<SetAdvertiseRoutes> for ControlRunner {
1283 type Reply = ();
1284
1285 async fn handle(&mut self, msg: SetAdvertiseRoutes, _ctx: &mut Context<Self, Self::Reply>) {
1286 tracing::debug!(n_routes = msg.routes.len(), "advertising routes to control");
1287 self.client.set_routable_ips(msg.routes).await;
1288 }
1289}
1290
1291/// Update this node's `Hostinfo.Hostname` at control — the wire half of a runtime
1292/// [`Runtime::set_hostname`](crate::Runtime::set_hostname). A direct `ask` from the runtime, so the
1293/// change reaches the live map-poll client.
1294#[derive(Debug)]
1295pub struct SetHostname {
1296 /// The new hostname to report to control.
1297 pub hostname: String,
1298}
1299
1300impl Message<SetHostname> for ControlRunner {
1301 type Reply = ();
1302
1303 async fn handle(&mut self, msg: SetHostname, _ctx: &mut Context<Self, Self::Reply>) {
1304 tracing::debug!("updating hostname at control");
1305 self.client.set_hostname(msg.hostname).await;
1306 }
1307}
1308
1309#[cfg(test)]
1310mod reauth_bridge_tests {
1311 use tokio::sync::watch;
1312
1313 use super::bridge_reauth_url_to_state;
1314 use crate::DeviceState;
1315
1316 fn url(s: &str) -> url::Url {
1317 s.parse().unwrap()
1318 }
1319
1320 /// The bridge maps a surfaced re-auth URL onto `DeviceState::NeedsLogin(url)` — the fix's core:
1321 /// a mid-session `MachineNotAuthorized` (forwarded by the control client as `Some(url)`) becomes
1322 /// the "needs login" state the IPN bus turns into `browse_to_url`.
1323 #[test]
1324 fn bridge_maps_auth_url_to_needs_login() {
1325 let u = url("https://login.example/auth");
1326 let (tx, rx) = watch::channel(DeviceState::Running);
1327
1328 bridge_reauth_url_to_state(&tx, Some(&u));
1329
1330 assert_eq!(*rx.borrow(), DeviceState::NeedsLogin(u));
1331 }
1332
1333 /// `None` never drives a transition — the recovery to `Running` is the netmap self-node
1334 /// handler's job, so the bridge ignores a `None` and leaves the state untouched.
1335 #[test]
1336 fn bridge_none_leaves_state_unchanged() {
1337 let (tx, rx) = watch::channel(DeviceState::Running);
1338
1339 bridge_reauth_url_to_state(&tx, None);
1340
1341 assert_eq!(*rx.borrow(), DeviceState::Running);
1342 }
1343
1344 /// Re-surfacing the same URL across retries does not re-fire the watch (`send_if_modified`
1345 /// dedupe against the cell's current value), so a stuck re-auth does not thrash subscribers.
1346 #[test]
1347 fn bridge_same_url_does_not_refire() {
1348 let u = url("https://login.example/auth");
1349 let (tx, mut rx) = watch::channel(DeviceState::Running);
1350
1351 bridge_reauth_url_to_state(&tx, Some(&u)); // first: fires
1352 assert!(rx.has_changed().unwrap(), "first NeedsLogin fires");
1353 rx.mark_unchanged();
1354 bridge_reauth_url_to_state(&tx, Some(&u)); // same URL: deduped
1355 assert!(
1356 !rx.has_changed().unwrap(),
1357 "the same re-auth URL must not re-fire the state watch"
1358 );
1359 }
1360
1361 /// A genuinely different re-auth URL after a prior one fires again (the dedupe tracks changes,
1362 /// it does not pin the first URL forever).
1363 #[test]
1364 fn bridge_new_url_after_prior_fires() {
1365 let a = url("https://login.example/a");
1366 let b = url("https://login.example/b");
1367 let (tx, rx) = watch::channel(DeviceState::Running);
1368
1369 bridge_reauth_url_to_state(&tx, Some(&a));
1370 bridge_reauth_url_to_state(&tx, Some(&b));
1371
1372 assert_eq!(*rx.borrow(), DeviceState::NeedsLogin(b));
1373 }
1374
1375 /// End-to-end of the *clear* contract: after the bridge sets `NeedsLogin`, the netmap self-node
1376 /// path (modeled here as a direct `send_replace(Running)`, the exact transition the
1377 /// `StreamMessage::Next` handler performs on the next good self-node) flips back to `Running`.
1378 /// This pins that the bridge does NOT need a `None`-clear arm — recovery is owned elsewhere.
1379 #[test]
1380 fn running_netmap_clears_needs_login() {
1381 let u = url("https://login.example/auth");
1382 let (tx, rx) = watch::channel(DeviceState::Running);
1383
1384 bridge_reauth_url_to_state(&tx, Some(&u));
1385 assert_eq!(*rx.borrow(), DeviceState::NeedsLogin(u));
1386
1387 // The self-node handler's recovery transition (next good netmap self-node → Running).
1388 tx.send_replace(DeviceState::Running);
1389 assert_eq!(*rx.borrow(), DeviceState::Running);
1390 }
1391}
1392
1393#[cfg(test)]
1394mod sticky_pop_browser_url_tests {
1395 use tokio::sync::watch;
1396
1397 use super::sticky_update_pop_browser_url;
1398
1399 fn url(s: &str) -> url::Url {
1400 s.parse().unwrap()
1401 }
1402
1403 /// A non-empty URL publishes to the cell.
1404 #[test]
1405 fn non_empty_url_publishes() {
1406 let (tx, rx) = watch::channel(None);
1407 let u = url("https://login.example/consent");
1408 sticky_update_pop_browser_url(&tx, Some(&u));
1409 assert_eq!(*rx.borrow(), Some(u));
1410 }
1411
1412 /// An absent (`None`) update — the common netmap tick — must NOT reset the cell. This is the
1413 /// regression guard for the thrash bug (a reset-every-tick would coalesce the URL away on the bus).
1414 #[test]
1415 fn absent_update_does_not_reset() {
1416 let u = url("https://login.example/consent");
1417 let (tx, rx) = watch::channel(Some(u.clone()));
1418 // Simulate many empty netmap updates.
1419 for _ in 0..5 {
1420 sticky_update_pop_browser_url(&tx, None);
1421 }
1422 assert_eq!(
1423 *rx.borrow(),
1424 Some(u),
1425 "empty updates must not clear the URL"
1426 );
1427 }
1428
1429 /// The same URL repeated does not re-fire the watch (in-place dedupe via `send_if_modified`), so
1430 /// a subscriber isn't woken spuriously. Proven by the borrow not having been marked changed.
1431 #[test]
1432 fn repeated_same_url_does_not_refire() {
1433 let u = url("https://login.example/consent");
1434 let (tx, mut rx) = watch::channel(None);
1435 sticky_update_pop_browser_url(&tx, Some(&u)); // first: fires
1436 assert!(rx.has_changed().unwrap(), "first non-empty URL fires");
1437 rx.mark_unchanged();
1438 sticky_update_pop_browser_url(&tx, Some(&u)); // same: deduped
1439 assert!(
1440 !rx.has_changed().unwrap(),
1441 "repeating the same URL must not re-fire the watch"
1442 );
1443 }
1444
1445 /// A genuinely new URL after a prior one fires again (sticky but tracks changes).
1446 #[test]
1447 fn new_url_after_prior_fires() {
1448 let a = url("https://login.example/a");
1449 let b = url("https://login.example/b");
1450 let (tx, rx) = watch::channel(None);
1451 sticky_update_pop_browser_url(&tx, Some(&a));
1452 sticky_update_pop_browser_url(&tx, Some(&b));
1453 assert_eq!(*rx.borrow(), Some(b));
1454 }
1455
1456 /// The realistic session sequence: a URL stays sticky through a run of `None` ticks, and a
1457 /// *different* URL after that gap still fires. Chains the legs the other tests cover in isolation
1458 /// (the actual control cadence is "URL, then many empty updates, then maybe a new URL").
1459 #[test]
1460 fn sticky_through_none_gap_then_new_url_fires() {
1461 let a = url("https://login.example/a");
1462 let b = url("https://login.example/b");
1463 let (tx, rx) = watch::channel(None);
1464 sticky_update_pop_browser_url(&tx, Some(&a));
1465 for _ in 0..3 {
1466 sticky_update_pop_browser_url(&tx, None);
1467 }
1468 assert_eq!(*rx.borrow(), Some(a), "stayed sticky through the None gap");
1469 sticky_update_pop_browser_url(&tx, Some(&b));
1470 assert_eq!(
1471 *rx.borrow(),
1472 Some(b),
1473 "a new URL after a None gap still fires"
1474 );
1475 }
1476
1477 /// Returning to a previously-seen URL (A → B → A) re-fires: the dedupe is against the cell's
1478 /// *current* value, not a full history, so A after B is a genuine change.
1479 #[test]
1480 fn returning_to_prior_url_refires() {
1481 let a = url("https://login.example/a");
1482 let b = url("https://login.example/b");
1483 let (tx, mut rx) = watch::channel(None);
1484 sticky_update_pop_browser_url(&tx, Some(&a));
1485 sticky_update_pop_browser_url(&tx, Some(&b));
1486 rx.mark_unchanged();
1487 sticky_update_pop_browser_url(&tx, Some(&a)); // back to A: differs from current (B) → fires
1488 assert!(
1489 rx.has_changed().unwrap(),
1490 "returning to a prior URL re-fires"
1491 );
1492 assert_eq!(*rx.borrow(), Some(a));
1493 }
1494
1495 /// End-to-end de-thrash: feed a realistic netmap cadence (empty, empty, URL, empty, empty)
1496 /// through the producer into a cell, and count the changes a `run_bus`-style subscriber would
1497 /// observe via `changed()`. The whole point of the fix is that exactly ONE change survives the
1498 /// surrounding `None` thrash — the pre-fix code (`send_replace` every tick) would have woken the
1499 /// subscriber on every empty tick and coalesced the URL away. This exercises the producer + the
1500 /// watch-subscribe path together (the two halves the unit tests cover in isolation).
1501 #[tokio::test]
1502 async fn end_to_end_one_change_survives_none_thrash() {
1503 let u = url("https://login.example/consent");
1504 let (tx, mut rx) = watch::channel(None);
1505 // The cadence control actually sends: mostly-empty MapResponses with one carrying the URL.
1506 let cadence = [None, None, Some(&u), None, None];
1507 for incoming in cadence {
1508 sticky_update_pop_browser_url(&tx, incoming);
1509 }
1510 // A subscriber sees exactly one change, and it carries the URL (not a coalesced `None`).
1511 let mut changes = 0;
1512 while rx.has_changed().unwrap() {
1513 let v = rx.borrow_and_update().clone();
1514 changes += 1;
1515 assert_eq!(v, Some(u.clone()), "the surviving change carries the URL");
1516 }
1517 assert_eq!(changes, 1, "exactly one change survives the None thrash");
1518 }
1519}
1520
1521#[cfg(test)]
1522mod home_region_hysteresis_tests {
1523 use core::time::Duration;
1524
1525 use ts_derp::RegionId;
1526 use ts_netcheck::RegionResult;
1527
1528 use super::select_home_region;
1529
1530 fn region(id: u32, latency_ms: u64) -> RegionResult {
1531 RegionResult {
1532 latency: Duration::from_millis(latency_ms),
1533 id: RegionId(core::num::NonZeroU32::new(id).unwrap()),
1534 latency_map_key: format!("region-{id}"),
1535 connected_remote: "127.0.0.1:0".parse().unwrap(),
1536 }
1537 }
1538
1539 fn rid(id: u32) -> RegionId {
1540 RegionId(core::num::NonZeroU32::new(id).unwrap())
1541 }
1542
1543 /// Empty measurements yield no selection.
1544 #[test]
1545 fn empty_measurements_select_none() {
1546 assert!(select_home_region(Some(rid(1)), &[]).is_none());
1547 assert!(select_home_region(None, &[]).is_none());
1548 }
1549
1550 /// First selection (no current home region) takes the best (lowest-latency) region directly.
1551 #[test]
1552 fn first_selection_takes_best() {
1553 let m = [region(1, 20), region(2, 50)];
1554 assert_eq!(select_home_region(None, &m).unwrap().id, rid(1));
1555 }
1556
1557 /// Jitter within the 10ms absolute-diff band keeps the current region (no flap). Current=region 2
1558 /// at 25ms; new best=region 1 at 20ms (only 5ms better) -> keep region 2.
1559 #[test]
1560 fn keeps_current_when_within_absolute_diff() {
1561 let m = [region(1, 20), region(2, 25)];
1562 let sel = select_home_region(Some(rid(2)), &m).unwrap();
1563 assert_eq!(
1564 sel.id,
1565 rid(2),
1566 "a 5ms improvement (< 10ms) must not flap the home region"
1567 );
1568 }
1569
1570 /// A meaningful improvement (>10ms AND best <= 2/3 of current) switches. Current=region 2 at
1571 /// 100ms; new best=region 1 at 20ms -> switch to region 1.
1572 #[test]
1573 fn switches_on_meaningful_improvement() {
1574 let m = [region(1, 20), region(2, 100)];
1575 assert_eq!(
1576 select_home_region(Some(rid(2)), &m).unwrap().id,
1577 rid(1),
1578 "a large improvement must switch the home region"
1579 );
1580 }
1581
1582 /// The two-thirds rule: even past the 10ms absolute diff, an improvement that does not beat 2/3
1583 /// of the current latency keeps the current region. Current=region 2 at 30ms; best=region 1 at
1584 /// 21ms: diff is 9ms (< 10ms keeps anyway) — use 30 vs 21 where diff=9ms. To isolate the 2/3 rule,
1585 /// use current=60ms, best=45ms: diff=15ms (>10ms, so the absolute test alone would switch), but
1586 /// 45 > 60*2/3=40, so keep.
1587 #[test]
1588 fn keeps_current_when_two_thirds_rule_not_met() {
1589 let m = [region(1, 45), region(2, 60)];
1590 let sel = select_home_region(Some(rid(2)), &m).unwrap();
1591 assert_eq!(
1592 sel.id,
1593 rid(2),
1594 "best (45ms) is not <= 2/3 of current (40ms), so keep current despite >10ms diff"
1595 );
1596 }
1597
1598 /// When the current home region is no longer present in the measurements, take the new best.
1599 #[test]
1600 fn switches_when_current_region_absent() {
1601 let m = [region(1, 20), region(3, 25)];
1602 assert_eq!(
1603 select_home_region(Some(rid(2)), &m).unwrap().id,
1604 rid(1),
1605 "a current region absent from the measurements falls through to the best"
1606 );
1607 }
1608
1609 /// When the best already IS the current home region, it is kept (no spurious change).
1610 #[test]
1611 fn keeps_current_when_it_is_already_best() {
1612 let m = [region(2, 20), region(1, 50)];
1613 assert_eq!(select_home_region(Some(rid(2)), &m).unwrap().id, rid(2));
1614 }
1615}