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