Skip to main content

tailscale/
lib.rs

1//! A work-in-progress [Tailscale](https://tailscale.com/blog/how-tailscale-works) library.
2//!
3//! `tailscale` allows Rust programs to connect to a tailnet and exchange traffic with peers over
4//! TCP and UDP. It can communicate with other `tailscale`-based peers, `tailscaled` (the Tailscale
5//! Go client), `tsnet`, and `libtailscale` via public DERP servers.
6//!
7//! <div class="warning">
8//! `tailscale` is unstable and insecure.
9//!
10//! We welcome enthusiasm and interest, but please **do not** build production software using these
11//! libraries or rely on it for data privacy until we have a chance to batten down some hatches and
12//! complete a third-party audit.
13//!
14//! See the [Caveats section](#caveats) for more details.
15//! </div>
16//!
17//! For language bindings, see the following crates:
18//!
19//! - C: [ts_ffi](https://docs.rs/ts_ffi)
20//! - Python: [ts_python](https://docs.rs/ts_python)
21//! - Elixir: [ts_elixir](https://docs.rs/ts_elixir)
22//!
23//! For instructions on how to run tests, lints, etc., see [CONTRIBUTING.md]. For the high-level
24//! architecture and repository layout, see [ARCHITECTURE.md].
25//!
26//! ## Code Sample
27//!
28//! A simple UDP client that periodically sends messages to a tailnet peer at `100.64.0.1:5678`:
29//!
30//! ```no_run
31//! # use std::{
32//! #     time::Duration,
33//! #     net::Ipv4Addr,
34//! #     error::Error,
35//! # };
36//! #
37//! # #[tokio::main]
38//! # async fn main() -> Result<(), Box<dyn Error>> {
39//! // Open a new connection to the tailnet
40//! let dev = tailscale::Device::new(
41//!     &tailscale::Config::default_with_key_file("tsrs_keys.json").await?,
42//!     Some("YOUR_AUTH_KEY_HERE".to_owned()),
43//! ).await?;
44//!
45//! // Bind a UDP socket on our tailnet IP, port 1234
46//! let sock = dev.udp_bind((dev.ipv4_addr().await?, 1234).into()).await?;
47//!
48//! // Send a packet containing "hello, world!" to 100.64.0.1:5678 once per second
49//! loop {
50//!     sock.send_to((Ipv4Addr::new(100, 64, 0, 1), 5678).into(), b"hello, world!").await?;
51//!     tokio::time::sleep(Duration::from_secs(1)).await;
52//! }
53//! # }
54//! ```
55//!
56//! Additional examples of using the `tailscale` crate can be found in the [`examples/`] directory.
57//!
58//! ## Using `tailscale`
59//!
60//! To use this crate or the language bindings, you will need to set the `TS_RS_EXPERIMENT` env var
61//! to `this_is_unstable_software`. We'll remove this requirement after a third-party code/cryptography
62//! audit and any necessary fixes.
63//!
64//! Under the hood, we use Tokio for our async runtime. You must also use Tokio, any kind and most
65//! configurations of Tokio runtimes should work, but there must be one available when you call any
66//! async API functions. The easiest way to do this is to use `#[tokio::main]`, see the
67//! [Tokio docs](https://docs.rs/tokio) for more information. In the future, we would like to limit
68//! our reliance on Tokio so that there are alternatives for users of other async runtimes.
69//!
70//! ## Caveats
71//!
72//! This software is still a work-in-progress! We are providing it in the open at this stage out of
73//! a belief in open-source and to see where the community runs with it, but please be aware of a
74//! few important considerations:
75//!
76//! - This implementation contains unaudited cryptography and hasn't undergone a comprehensive
77//!   security analysis. Conservatively, assume there could be a critical security hole meaning
78//!   anything you send or receive could be in the clear on the public Internet.
79//! - There are no compatibility guarantees at the moment. This is early-days software - we may
80//!   break dependent code in order to get things right.
81//! - Direct peer-to-peer connections via NAT traversal are implemented (STUN-discovered endpoints
82//!   and Disco, with `CallMeMaybe` hole-punching over DERP), with DERP relays as the fallback when
83//!   no direct path is available. Hard/symmetric NATs get the same single fixed-local-port candidate
84//!   (`EndpointSTUN4LocalPort`) Go Tailscale uses; behind a NAT with no static port mapping a flow
85//!   may still stay relayed through DERP, which caps its throughput. (Upstream Go does **not** do a
86//!   "256-port birthday-paradox spray" — that is a common misconception; the single-candidate guess
87//!   is the actual behavior, and this fork matches it.)
88//!
89//! ## Feature Flags
90//!
91//! - `axum`: enables the `axum` module, which enables you to run an `axum` HTTP server on top
92//!   of a [`netstack::TcpListener`].
93//! - `tsnet`: enables the `tsnet` module — a Go-idiomatic `tsnet.Server`-shaped facade over
94//!   [`Device`] and [`Config`]. Set fields, then call `up`/`listen`/`dial`/`loopback`/`close`/…, and
95//!   the wrapped [`Device`] is built and started lazily on the first call (Go's "fields may be
96//!   changed until the first method call"). A thin ergonomics layer only — same engine, same typed
97//!   returns. See the [`tsnet` module docs] for the full Go `tsnet.Server` → Rust API mapping.
98//!
99//! ## Platform Support
100//!
101//! `tailscale` currently supports the following platforms:
102//!
103//! - Linux (x86_64 and ARM64)
104//! - macOS (ARM64)
105//!
106//! ## Component crates
107//!
108//! The following crates are part of the tailscale-rs project and are dependencies of this one. For
109//! many tasks, just this crate should be sufficient and these other crates are an implementation detail.
110//! There are other crates too, see [ARCHITECTURE.md]
111//! or the [GitHub repo](https://github.com/tailscale/tailscale-rs).
112//!
113//! - [ts_runtime](https://docs.rs/ts_runtime): for each API-level `Device`, the runtime uses an actor
114//!   architecture to manage the lifecycle of the control client, data plane components, netstack, etc.
115//!   A message bus passes updates and communications between these top-level actors.
116//! - [ts_netcheck](https://docs.rs/ts_netcheck): checks network availability and reports latency to
117//!   DERP servers in different regions.
118//! - [ts_netstack_smoltcp](https://docs.rs/ts_netstack_smoltcp): a [smoltcp](https://docs.rs/smoltcp)-based
119//!   network stack that processes Layer 3+ packets to/from the overlay network.
120//! - [ts_control](https://docs.rs/ts_control): control plane client that handles registration,
121//!   authorization/authentication, configuration, and streaming updates.
122//! - [ts_dataplane](https://docs.rs/ts_dataplane): wires all the individual data plane functions together,
123//!   flowing inbound and outbound packets through the components in the correct order.
124//! - [ts_tunnel](https://docs.rs/ts_tunnel): a partial implementation of the WireGuard specification
125//!   that protects all data plane traffic, and is interoperable with other WireGuard clients, including Tailscale clients.
126//! - [ts_cli_util](https://docs.rs/ts_cli_util): helpers for writing command line tools and initializing
127//!   logging, used in examples.
128//! - [ts_disco_protocol](https://docs.rs/ts_disco_protocol): incomplete implementation of Tailscale's
129//!   discovery protocol (disco).
130//!
131//! [ARCHITECTURE.md]: https://github.com/tailscale/tailscale-rs/blob/main/ARCHITECTURE.md
132//! [CONTRIBUTING.md]: https://github.com/tailscale/tailscale-rs/blob/main/CONTRIBUTING.md
133//! [`examples/`]: https://github.com/tailscale/tailscale-rs/blob/main/examples/README.md
134//! [open an issue]: https://github.com/tailscale/tailscale-rs/issues
135//! [`axum` HTTP server]: https://docs.rs/axum/latest/axum/
136//! [`tsnet` module docs]: https://docs.rs/geiserx_tailscale/latest/tailscale/tsnet/
137
138use std::{
139    net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
140    time::Duration,
141};
142
143#[doc(inline)]
144pub use config::Config;
145#[doc(inline)]
146pub use error::{Error, InternalErrorKind};
147// Re-exported so a downstream crate depending only on `tailscale` can name the auth-key secret type
148// for [`Device::new_with_secret`] without taking a separate, version-pinned dependency on `secrecy`
149// (which would risk a `SecretString`-type mismatch if the two `secrecy` majors diverged). Callers
150// pass `tailscale::SecretString`; `secrecy` is a pure-Rust wrapper (no aws-lc/openssl/ring).
151pub use secrecy::SecretString;
152#[doc(inline)]
153pub use ts_control::ExitNodeSelector;
154#[doc(inline)]
155pub use ts_control::Node as NodeInfo;
156// Re-exported so an embedder authorising an inbound connection on `WhoIs::user_profile` can name
157// the profile type — and match on its `groups` — through the `tailscale` facade alone, without
158// taking a direct dependency on `ts_control`.
159#[doc(inline)]
160pub use ts_control::UserProfile;
161#[doc(inline)]
162pub use ts_control::tls::{CertifiedKey, TlsAcceptor, TlsStream};
163#[doc(inline)]
164pub use ts_control::{CertError, MISSING_CERT_RPC, ServeConfig, ServeState, ServeTarget};
165/// The netmap DNS configuration returned by [`Device::dns_config`] (Go `netmap.NetworkMap.DNS`).
166#[doc(inline)]
167pub use ts_control::{DnsConfig, DnsResolver, ExtraRecord};
168#[doc(inline)]
169pub use ts_control::{ExitProxyConfig, ExitProxyScheme};
170pub use ts_control::{
171    IdTokenError, LogoutError, ServiceError, ServiceMode, SetDnsError, SetDnsInternalErrorKind,
172    SshAccept, SshAction, SshConnIdentity, SshDecision, SshDenyReason, SshPolicy, SshPrincipal,
173    SshRule, StableNodeId,
174};
175// Re-exported so the application data-path transport can be selected through the `tailscale`
176// facade alone: `Config::transport_mode` is `TransportMode` (default `Netstack`; `Tun(TunConfig {
177// name, mtu })` for a real kernel TUN interface). Both are `pub` in `ts_control` but were not
178// reachable through this facade, forcing downstream crates to depend on `ts_control` directly just
179// to name them.
180pub use ts_control::{TransportMode, TunConfig};
181#[doc(inline)]
182pub use ts_netstack_smoltcp::PingError;
183use ts_netstack_smoltcp::{CreateSocket, netcore::Channel};
184#[doc(inline)]
185pub use ts_runtime::fallback_tcp::{
186    FallbackConnFuture, FallbackConnHandler, FallbackDecision, FallbackTcpHandle,
187};
188#[doc(inline)]
189pub use ts_runtime::taildrop::WaitingFile;
190#[doc(inline)]
191pub use ts_runtime::{
192    DeviceState, DnsQueryResult, ExitNodeSuggestion, FileTarget, IpnBusWatcher, NetcheckReport,
193    Notify, NotifyWatchOpt, RegionLatency, RegistrationError, Status, StatusNode, TkaLogEntry,
194    WhoIs,
195};
196/// The interactive-login URL type returned by [`Device::pop_browser_url`].
197#[doc(inline)]
198pub use url::Url;
199
200#[cfg(feature = "axum")]
201pub mod axum;
202pub mod config;
203mod dial;
204mod error;
205#[cfg(feature = "hyper")]
206pub mod http;
207mod loopback;
208#[cfg(feature = "ssh")]
209pub mod ssh;
210#[cfg(feature = "tsnet")]
211pub mod tsnet;
212
213#[doc(inline)]
214pub use dial::{ConnectedUdpSocket, DialConn};
215#[doc(inline)]
216pub use loopback::LoopbackHandle;
217
218/// How a program connects to a tailnet and communicates with peers.
219///
220/// The `Device` connects to the control plane, registers itself with the tailnet, and communicates
221/// with tailnet peers. Its tailnet identity is determined by the key state provided at
222/// construction-time.
223pub struct Device {
224    runtime: ts_runtime::Runtime,
225    /// Command channel to the application netstack. `None` in TUN transport mode, where there is
226    /// no userspace application netstack; the channel-driven socket APIs ([`Device::udp_bind`],
227    /// [`Device::tcp_listen`], [`Device::tcp_connect`], [`Device::ping`]) are unsupported there.
228    channel: Option<Channel>,
229    /// Whether IPv6 is enabled on the tailnet overlay (the `Config::enable_ipv6` gate, default
230    /// `false`). Captured at construction; used by [`Device::listen_service`] to decide whether an
231    /// IPv6 VIP-service address is bindable (the netstack only accepts IPv6 overlay addresses when
232    /// this is set).
233    enable_ipv6: bool,
234    /// The stored Serve config + its live per-port accept loops (`tsnet`'s `Get/SetServeConfig` +
235    /// serving runtime). Built lazily on the first [`Device::set_serve_config`] (it needs this
236    /// node's overlay IPv4, only known after registration). Held here so its accept loops abort when
237    /// the `Device` drops; `None` (empty config) until the first `set`.
238    serve: std::sync::Mutex<Option<ts_runtime::serve::ServeManager>>,
239    /// The live Funnel ingress manager (`tsnet`'s `ListenFunnel` data path), built on
240    /// [`Device::listen_funnel`](crate::Device::listen_funnel). Held here so its TLS-termination pump and the installed peerAPI
241    /// ingress sink stay alive for the device's life (and tear down when a new `listen_funnel`
242    /// replaces it, or the `Device` drops). `None` until the first `listen_funnel`.
243    funnel: std::sync::Mutex<Option<ts_runtime::funnel::FunnelManager>>,
244}
245
246/// Map a [`ts_runtime::taildrop::TaildropError`] to the device-facing [`Error`]. `Error` is a
247/// `Copy` enum with no payload, so the I/O detail string is dropped, but the *kind* is preserved so
248/// a caller can still distinguish the actionable cases: an invalid name →
249/// [`InternalErrorKind::BadRequest`], an in-progress conflict → [`InternalErrorKind::AlreadyExists`],
250/// a missing file → [`InternalErrorKind::NotFound`], and any other filesystem failure →
251/// [`InternalErrorKind::Io`].
252fn taildrop_err(e: ts_runtime::taildrop::TaildropError) -> Error {
253    use ts_runtime::taildrop::TaildropError;
254    match e {
255        TaildropError::InvalidFileName => Error::Internal(InternalErrorKind::BadRequest),
256        TaildropError::FileExists => Error::Internal(InternalErrorKind::AlreadyExists),
257        TaildropError::Io(io) if io.kind() == std::io::ErrorKind::NotFound => {
258            Error::Internal(InternalErrorKind::NotFound)
259        }
260        TaildropError::Io(_) => Error::Internal(InternalErrorKind::Io),
261    }
262}
263
264/// Map a [`ts_runtime::taildrop_send::TaildropSendError`] (the Taildrop *sender*) to the
265/// device-facing [`Error`]. The send-side conflict/forbidden/unexpected-status cases all reduce to
266/// `BadRequest` (the peer refused the transfer for a request-level reason), a dial failure or
267/// timeout to `Timeout`, an invalid name to `BadRequest`, and any stream I/O failure to `Io`.
268fn taildrop_send_err(e: ts_runtime::taildrop_send::TaildropSendError) -> Error {
269    use ts_runtime::taildrop_send::TaildropSendError;
270    match e {
271        TaildropSendError::Connect | TaildropSendError::Timeout => Error::Timeout,
272        TaildropSendError::InvalidName
273        | TaildropSendError::Forbidden
274        | TaildropSendError::Conflict
275        | TaildropSendError::UnexpectedStatus(_) => Error::Internal(InternalErrorKind::BadRequest),
276        TaildropSendError::Io => Error::Internal(InternalErrorKind::Io),
277    }
278}
279
280/// Resolve the peerAPI destination for a Taildrop send, preferring the peer tracker's `current`
281/// record over the caller-held `snapshot`.
282///
283/// Split out of [`Device::send_file`] so the decision is testable without a live device. The two
284/// refusals are the ones `send_file` documents:
285///
286/// - an [`expired`][ts_control::Node::expired] peer is refused with [`Error::PeerKeyExpired`], the
287///   reason rather than a generic bad request — Go `LocalBackend.pingPeerAPI` answers "peer's node
288///   key has expired" here. Checked before `peerapi_addr`, which also refuses an expired peer but
289///   can only say `None`; being able to name the cause is why an expired peer is kept in the netmap
290///   instead of dropped from it.
291/// - the destination comes only from the peer's own node record, and is asserted to be a Tailscale
292///   CGNAT address before it is dialed, so a malformed record cannot steer the PUT at a non-tailnet
293///   host.
294///
295/// `current` is `None` when the tracker holds no peer with that stable id — it left the tailnet, or
296/// no netmap has arrived. The snapshot is then all there is, and using it keeps the pre-existing
297/// behaviour; the CGNAT assertion still bounds where the send can go.
298fn taildrop_dst(snapshot: &NodeInfo, current: Option<&NodeInfo>) -> Result<SocketAddr, Error> {
299    let peer = current.unwrap_or(snapshot);
300
301    if peer.expired {
302        return Err(Error::PeerKeyExpired);
303    }
304
305    let dst = peer
306        .peerapi_addr()
307        .ok_or(Error::Internal(InternalErrorKind::BadRequest))?;
308    if !ts_control::is_tailscale_ip(dst.ip()) {
309        return Err(Error::Internal(InternalErrorKind::BadRequest));
310    }
311
312    Ok(dst)
313}
314
315/// Resolve the effective registration auth key from `auth_key` plus the config's
316/// workload-identity-federation (WIF) / OAuth-client fields.
317///
318/// With the `identity-federation` feature enabled, an OAuth client secret (`tskey-client-…`) or a
319/// `client_id` + (`id_token` | `audience`) is exchanged for a Tailscale auth key against the SaaS
320/// admin API before registration (Go `tsnet.Server`'s `resolveAuthKey`). Without the feature this is
321/// a pure pass-through: `auth_key` is returned unchanged and the WIF config fields are ignored, so
322/// the default build is byte-identical to before.
323#[cfg(feature = "identity-federation")]
324async fn resolve_auth_key(
325    config: &Config,
326    auth_key: Option<String>,
327) -> Result<Option<String>, Error> {
328    let wif = ts_control::WifConfig {
329        auth_key,
330        client_id: config.client_id.clone(),
331        client_secret: config.client_secret.clone(),
332        id_token: config.id_token.clone(),
333        audience: config.audience.clone(),
334        tags: config.requested_tags.clone(),
335    };
336    ts_control::resolve_auth_key(&wif, &config.control_server_url)
337        .await
338        .map_err(|e| {
339            tracing::error!(error = %e, "resolving auth key via workload-identity federation");
340            Error::Internal(InternalErrorKind::BadRequest)
341        })
342}
343
344/// Pass-through when the `identity-federation` feature is disabled: the auth key is used as-is and
345/// the WIF config fields have no effect (matching Go, where the federation path is compiled out
346/// unless its optional feature is linked).
347#[cfg(not(feature = "identity-federation"))]
348async fn resolve_auth_key(
349    _config: &Config,
350    auth_key: Option<String>,
351) -> Result<Option<String>, Error> {
352    Ok(auth_key)
353}
354
355impl Device {
356    /// Create a device from the given [`Config`] and auth key.
357    ///
358    /// Internally, this will spawn multiple asynchronous actors onto a Tokio runtime.
359    ///
360    /// # Example
361    ///
362    /// ```rust,no_run
363    /// # #[tokio::main]
364    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
365    /// # use tailscale::*;
366    /// let dev = Device::new(
367    ///     &Config::default_with_key_file("tsrs_keys.json").await?,
368    ///     Some("MY_AUTH_KEY".to_string()),
369    /// ).await?;
370    /// # Ok(()) }
371    /// ```
372    pub async fn new(config: &Config, auth_key: Option<String>) -> Result<Self, Error> {
373        check_magic_env()?;
374
375        // Resolve the effective registration auth key. The explicit `auth_key` argument wins; if it
376        // is `None`, fall back to `config.auth_key` (Go `tsnet.Server.AuthKey`). When the
377        // `identity-federation` feature is enabled, the resolved key is further passed through the
378        // WIF / OAuth-client bootstrap, which exchanges an OAuth client secret (`tskey-client-…`) or
379        // an IdP-issued OIDC token for a Tailscale auth key before registration (SaaS-only).
380        let auth_key = auth_key.or_else(|| config.auth_key.clone());
381        let auth_key = resolve_auth_key(config, auth_key).await?;
382
383        let rt =
384            ts_runtime::Runtime::spawn(config.into(), auth_key, (&config.key_state).into()).await?;
385        // In TUN transport mode there is no application netstack, so the runtime has no command
386        // channel: that surfaces as `UnsupportedInTunMode`, which we map to a `None` channel rather
387        // than an error (the device is still usable for control-plane and peer-lookup APIs).
388        let channel = match rt.channel().await {
389            Ok(c) => Some(c),
390            Err(e) if e.kind == ts_runtime::ErrorKind::UnsupportedInTunMode => None,
391            Err(e) => return Err(e.into()),
392        };
393
394        Ok(Self {
395            runtime: rt,
396            channel,
397            enable_ipv6: config.enable_ipv6,
398            serve: std::sync::Mutex::new(None),
399            funnel: std::sync::Mutex::new(None),
400        })
401    }
402
403    /// Create a device from the given [`Config`] and a [`SecretString`] auth key.
404    ///
405    /// This is a back-compat-preserving convenience over [`new`](Self::new) for callers that already
406    /// hold the registration auth key as a [`secrecy::SecretString`] (e.g. a daemon that keeps the
407    /// pre-auth key wrapped end-to-end). It lets the caller avoid materializing a plain `String` at
408    /// the engine boundary: the secret is exposed only on the last inch, immediately before being
409    /// handed to [`new`](Self::new).
410    ///
411    /// # Honesty about the plaintext window
412    ///
413    /// This closes the *caller's* boundary, **not** the engine's internal handling. The engine still
414    /// resolves the auth key to a plain `String` internally for registration (the plaintext `String`
415    /// window inside the engine is identical to calling [`new`](Self::new) directly) — this method
416    /// does not make the engine itself secret-clean. If you call [`new`](Self::new) you create that
417    /// `String` yourself; if you call this you do not, but the engine creates one either way.
418    ///
419    /// Passing `None` is equivalent to `new(config, None)` (falls back to `config.auth_key`).
420    ///
421    /// # Example
422    ///
423    /// ```rust,no_run
424    /// # #[tokio::main]
425    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
426    /// # use tailscale::*;
427    /// let dev = Device::new_with_secret(
428    ///     &Config::default_with_key_file("tsrs_keys.json").await?,
429    ///     Some(SecretString::from("MY_AUTH_KEY")),
430    /// ).await?;
431    /// # Ok(()) }
432    /// ```
433    pub async fn new_with_secret(
434        config: &Config,
435        auth_key: Option<SecretString>,
436    ) -> Result<Self, Error> {
437        use secrecy::ExposeSecret as _;
438
439        // Expose the secret on the last inch and delegate to `new`, so the spawn/registration path
440        // is shared verbatim (no duplicated runtime-spawn logic) and the engine-internal plaintext
441        // window is byte-for-byte identical to a direct `new` call.
442        let plain = auth_key.map(|s| s.expose_secret().to_string());
443        Self::new(config, plain).await
444    }
445
446    /// The application netstack command channel, or an error in TUN transport mode (no application
447    /// netstack exists).
448    fn channel(&self) -> Result<&Channel, Error> {
449        self.channel
450            .as_ref()
451            .ok_or(Error::Internal(InternalErrorKind::UnsupportedInTunMode))
452    }
453
454    /// Get this [`Device`]'s IPv4 tailnet address.
455    pub async fn ipv4_addr(&self) -> Result<Ipv4Addr, Error> {
456        self.runtime
457            .control
458            .ask(ts_runtime::control_runner::Ipv4)
459            .await
460            .map_err(ts_runtime::Error::from)?
461            .ok_or(Error::Internal(InternalErrorKind::Actor))
462    }
463
464    /// Get this [`Device`]'s IPv6 tailnet address.
465    pub async fn ipv6_addr(&self) -> Result<Ipv6Addr, Error> {
466        self.runtime
467            .control
468            .ask(ts_runtime::control_runner::Ipv6)
469            .await
470            .map_err(ts_runtime::Error::from)?
471            .ok_or(Error::Internal(InternalErrorKind::Actor))
472    }
473
474    /// This node's tailnet IPv4 and (when provisioned) IPv6 addresses as a pair — the Rust analog of
475    /// Go `tsnet.Server.TailscaleIPs() (ip4, ip6 netip.Addr)`.
476    ///
477    /// Reads the self node's assigned addresses (the same source Go splits by family). The tailnet
478    /// is IPv4-only unless [`Config::enable_ipv6`](crate::config::Config) is set, so the IPv6 half is
479    /// `None` when no v6 address is assigned — the Rust shape for Go returning the zero `netip.Addr`
480    /// in that case (Go's IPv6-absent sentinel). Errors until the first netmap is received (no self
481    /// node yet), matching Go returning invalid addresses before the node has joined.
482    pub async fn tailscale_ips(&self) -> Result<(Ipv4Addr, Option<Ipv6Addr>), Error> {
483        let me = self.self_node().await?;
484        let v4 = me.tailnet_address.ipv4.addr();
485        let v6 = me.tailnet_address.ipv6.addr();
486        // The decoder synthesizes the unspecified `::` placeholder on an IPv4-only tailnet; surface
487        // a real v6 only when IPv6 is enabled AND a non-placeholder address was assigned.
488        let v6 = (self.enable_ipv6 && !v6.is_unspecified()).then_some(v6);
489        Ok((v4, v6))
490    }
491
492    /// Bind a UDP socket to the specified [`SocketAddr`].
493    ///
494    /// Returns an error in TUN transport mode (there is no application netstack to bind on).
495    pub async fn udp_bind(&self, socket_addr: SocketAddr) -> Result<netstack::UdpSocket, Error> {
496        self.channel()?
497            .udp_bind(socket_addr)
498            .await
499            .map_err(Into::into)
500    }
501
502    /// Bind a TCP listener to the specified [`SocketAddr`].
503    ///
504    /// Returns an error in TUN transport mode (there is no application netstack to listen on).
505    pub async fn tcp_listen(
506        &self,
507        socket_addr: SocketAddr,
508    ) -> Result<netstack::TcpListener, Error> {
509        self.channel()?
510            .tcp_listen(socket_addr)
511            .await
512            .map_err(Into::into)
513    }
514
515    /// Register a fallback TCP handler (like `tsnet`'s `RegisterFallbackTCPHandler`).
516    ///
517    /// The callback is consulted for every inbound TCP flow that matches **no** explicit
518    /// [`Device::tcp_listen`] listener, with the flow's `(src, dst)` addresses. It returns
519    /// `(handler, intercept)`:
520    /// - `(_, false)` — decline; the next registered callback is tried.
521    /// - `(Some(h), true)` — claim the flow; `h` is handed the accepted [`netstack::TcpStream`].
522    /// - `(None, true)` — claim and reject the flow (the connection is closed).
523    ///
524    /// Multiple handlers may be registered; they are consulted in registration order and the first
525    /// to intercept wins. The returned [`FallbackTcpHandle`] deregisters the handler when dropped.
526    ///
527    /// Handlers serve flows over the overlay netstack only — never a host socket — and a flow no
528    /// handler claims is closed (fail-closed), never direct-dialed.
529    ///
530    /// Returns an error in TUN transport mode (there is no application netstack to attach to).
531    pub fn register_fallback_tcp_handler<F>(&self, cb: F) -> Result<FallbackTcpHandle, Error>
532    where
533        F: Fn(SocketAddr, SocketAddr) -> FallbackDecision + Send + Sync + 'static,
534    {
535        self.runtime
536            .register_fallback_tcp_handler(std::sync::Arc::new(cb))
537            .map_err(Into::into)
538    }
539
540    /// Resolve a tailnet peer (or this node) by MagicDNS name to its tailnet IPv4 address.
541    ///
542    /// This is an in-process lookup against the netmap we already hold — like `tsnet`'s in-memory
543    /// `dnsMap`, it does not query any DNS server (there is no `100.100.100.100` resolver). The
544    /// `name` may be a bare hostname or a fully-qualified MagicDNS name, with or without a trailing
545    /// dot, in any case (matching is case-insensitive). Returns `Ok(None)` if no tailnet node has
546    /// that name.
547    ///
548    /// Only MagicDNS names are resolved; names outside the tailnet are not looked up here, so the
549    /// caller's system resolver remains responsible for them. IPv6 is intentionally not resolved —
550    /// this fork operates IPv4-only on the tailnet.
551    pub async fn resolve(&self, name: &str) -> Result<Option<Ipv4Addr>, Error> {
552        if let Some(peer) = self.peer_by_name(name).await? {
553            return Ok(Some(peer.tailnet_address.ipv4.addr()));
554        }
555
556        // tsnet's dnsMap also resolves our own name; fall back to self when no peer matches.
557        let me = self.self_node().await?;
558        if me.matches_name(name) {
559            return Ok(Some(me.tailnet_address.ipv4.addr()));
560        }
561
562        Ok(None)
563    }
564
565    /// Run a real DNS query through the tailnet's MagicDNS responder (the `100.100.100.100`
566    /// forward path), returning the raw response, RCODE, and resolver(s) consulted — the analogue of
567    /// Go `LocalClient.QueryDNS`.
568    ///
569    /// Unlike [`resolve`](Self::resolve) (an in-memory netmap lookup that answers only MagicDNS
570    /// A-records), this issues an actual query of any `qtype` and runs it through the live
571    /// responder: an authoritative tailnet name is answered locally, anything else is forwarded to
572    /// the configured split-DNS / recursive upstreams (or delegated to the active exit node's DoH).
573    /// The response is returned as raw bytes (matching Go's `QueryDNS`), since this fork's DNS wire
574    /// codec has no answer-record decoder; the caller parses records itself if needed.
575    ///
576    /// `qtype` is the raw RFC 1035 TYPE value (`1`=A, `28`=AAAA, `12`=PTR, `16`=TXT, `33`=SRV,
577    /// `65`=HTTPS/SVCB, …). Anti-leak is inherited from the responder: a tailnet-suffix name never
578    /// egresses, recursive forwards delegate to the exit node when one is active, and only IPv4
579    /// upstreams are dialed.
580    ///
581    /// Returns an [`Error::Internal`] with `InternalErrorKind::UnsupportedInTunMode` in TUN
582    /// transport mode (MagicDNS there is an in-packet intercept, not a queryable responder).
583    pub async fn query_dns(&self, name: &str, qtype: u16) -> Result<DnsQueryResult, Error> {
584        self.runtime
585            .query_dns(name, qtype)
586            .await
587            .map_err(Into::into)
588    }
589
590    /// Connect to a tailnet peer by MagicDNS name and port over TCP.
591    ///
592    /// Resolves `name` via [`Device::resolve`] (an in-process netmap lookup, no DNS server), then
593    /// dials the resulting tailnet IPv4 address. Returns [`InternalErrorKind::BadRequest`] if the
594    /// name does not resolve to a tailnet node.
595    pub async fn connect_by_name(
596        &self,
597        name: &str,
598        port: u16,
599    ) -> Result<netstack::TcpStream, Error> {
600        let addr = self
601            .resolve(name)
602            .await?
603            .ok_or(Error::Internal(InternalErrorKind::BadRequest))?;
604
605        self.tcp_connect((addr, port).into()).await
606    }
607
608    /// Resolve a `host:port` string to a tailnet [`SocketAddr`], honoring the family forced by a
609    /// `network` suffix. The host may be an IP literal (parsed directly) or a MagicDNS name
610    /// (resolved via [`Device::resolve`], which yields a tailnet IPv4). Shared by [`Device::dial`]
611    /// and [`Device::dial_tcp`]. The IPv4-only invariant is enforced here: a `…6` network, or any v6
612    /// destination, requires `Config::enable_ipv6` and otherwise returns
613    /// [`InternalErrorKind::BadRequest`] (a clean typed error rather than a downstream actor error).
614    async fn resolve_dial_addr(
615        &self,
616        network: dial::Network,
617        addr: &str,
618    ) -> Result<SocketAddr, Error> {
619        let (host, port) = dial::split_host_port(addr)?;
620
621        // An IP literal is used directly; otherwise resolve the MagicDNS name (IPv4 only).
622        let ip: IpAddr = if let Ok(ip) = host.parse::<IpAddr>() {
623            ip
624        } else {
625            self.resolve(host)
626                .await?
627                .ok_or(Error::Internal(InternalErrorKind::BadRequest))?
628                .into()
629        };
630
631        dial::check_family(network.family, ip)?;
632
633        // IPv4-only invariant: a v6 destination is only reachable when IPv6 is provisioned.
634        if ip.is_ipv6() && !self.enable_ipv6 {
635            return Err(Error::Internal(InternalErrorKind::BadRequest));
636        }
637
638        Ok((ip, port).into())
639    }
640
641    /// Connect to a tailnet address over TCP or UDP, the Rust analog of Go
642    /// `tsnet.Server.Dial(ctx, network, address)`.
643    ///
644    /// `network` is one of `"tcp"`, `"tcp4"`, `"tcp6"`, `"udp"`, `"udp4"`, `"udp6"`; `addr` is a
645    /// `host:port` string where `host` is a MagicDNS name, an IPv4 literal, or a bracketed IPv6
646    /// literal (`[2001:db8::1]:443`). The host is resolved in-process via [`Device::resolve`] (no DNS
647    /// server). Returns a [`DialConn`] whose arm matches the transport — use [`Device::dial_tcp`]
648    /// when you want the TCP stream directly.
649    ///
650    /// Differences from Go (documented for parity): ports must be **numeric** (Go's `LookupPort`
651    /// also resolves named ports like `"http"`; this fork avoids a services-file dependency), and
652    /// `…6`/v6 destinations require `Config::enable_ipv6` (the tailnet is IPv4-only by default).
653    ///
654    /// # Errors
655    /// [`InternalErrorKind::BadRequest`] for an unsupported `network`, a malformed/portless `addr`,
656    /// an unresolvable name, or a v6 destination while IPv6 is disabled; otherwise the transport's
657    /// own connect error.
658    pub async fn dial(&self, network: &str, addr: &str) -> Result<DialConn, Error> {
659        let net = dial::parse_network(network)?;
660        let remote = self.resolve_dial_addr(net, addr).await?;
661
662        match net.transport {
663            dial::Transport::Tcp => Ok(DialConn::Tcp(self.tcp_connect(remote).await?)),
664            dial::Transport::Udp => {
665                // Bind an ephemeral local UDP socket on this node's tailnet address of the SAME
666                // family as the remote, then connect it (Go's `Dial("udp", …)` returns a connected
667                // UDP `net.Conn`, with the local source picked by `IfElse(dst.Is6(), v6, v4)`). A v4
668                // local socket cannot send to a v6 peer, so the family must match `remote`. (TCP gets
669                // this for free: `tcp_connect` already picks the source family from `remote`.)
670                let local_ip: IpAddr = if remote.is_ipv6() {
671                    self.ipv6_addr().await?.into()
672                } else {
673                    self.ipv4_addr().await?.into()
674                };
675                let sock = self.udp_bind((local_ip, 0).into()).await?;
676                Ok(DialConn::Udp(ConnectedUdpSocket::new(sock, remote)))
677            }
678        }
679    }
680
681    /// Connect to a tailnet address over TCP, returning the stream directly — the common case of
682    /// [`Device::dial`] for `"tcp"`. `addr` is a `host:port` string (MagicDNS name or IP literal).
683    /// This is the building block for HTTP-over-tailnet: an embedder's `hyper`/`reqwest` client can
684    /// route requests by calling `dial_tcp(&format!("{host}:{port}"))` from its connector, mirroring
685    /// how Go `tsnet.Server.HTTPClient` sets `http.Transport.DialContext = Server.Dial`.
686    ///
687    /// # Errors
688    /// As [`Device::dial`] for the `"tcp"` network.
689    pub async fn dial_tcp(&self, addr: &str) -> Result<netstack::TcpStream, Error> {
690        let remote = self
691            .resolve_dial_addr(
692                dial::Network {
693                    transport: dial::Transport::Tcp,
694                    family: dial::Family::Any,
695                },
696                addr,
697            )
698            .await?;
699        self.tcp_connect(remote).await
700    }
701
702    /// Connect to a tailnet address over UDP, returning a connected socket directly — the `"udp"`
703    /// sibling of [`dial_tcp`](Device::dial_tcp) and the common case of [`Device::dial`] for
704    /// `"udp"`. `addr` is a `host:port` string (MagicDNS name or IP literal).
705    ///
706    /// Returns a [`ConnectedUdpSocket`] (`send`/`recv` against a fixed peer), the connected
707    /// UDP-`net.Conn` shape Go's `tsnet.Server.Dial("udp", …)` returns — as opposed to
708    /// [`listen_packet`](Device::listen_packet), which yields an unconnected `net.PacketConn`. An
709    /// ephemeral local UDP socket is bound on this node's tailnet address of the same family as the
710    /// resolved remote (a v4 local socket cannot send to a v6 peer).
711    ///
712    /// # Errors
713    /// As [`Device::dial`] for the `"udp"` network (name resolution, the IPv4-only / `enable_ipv6`
714    /// family invariant, or TUN transport mode having no application netstack to bind on).
715    pub async fn dial_udp(&self, addr: &str) -> Result<ConnectedUdpSocket, Error> {
716        let remote = self
717            .resolve_dial_addr(
718                dial::Network {
719                    transport: dial::Transport::Udp,
720                    family: dial::Family::Any,
721                },
722                addr,
723            )
724            .await?;
725        let local_ip: IpAddr = if remote.is_ipv6() {
726            self.ipv6_addr().await?.into()
727        } else {
728            self.ipv4_addr().await?.into()
729        };
730        let sock = self.udp_bind((local_ip, 0).into()).await?;
731        Ok(ConnectedUdpSocket::new(sock, remote))
732    }
733
734    /// Bind a UDP socket from a `host:port` string, the Rust analog of Go
735    /// `tsnet.Server.ListenPacket(network, addr)`.
736    ///
737    /// `network` is one of `"udp"`, `"udp4"`, `"udp6"`; `addr` must be a **valid IP literal**
738    /// `host:port` (Go's `ListenPacket` rejects a name or empty host — unlike `Listen`). An
739    /// unspecified host (`0.0.0.0`/`[::]`) binds on this node's tailnet address. Returns the
740    /// unconnected [`netstack::UdpSocket`] (a `net.PacketConn`).
741    ///
742    /// # Errors
743    /// [`InternalErrorKind::BadRequest`] for a non-UDP/unsupported `network`, a malformed addr, a
744    /// non-IP host, a family mismatch, or a v6 bind while IPv6 is disabled.
745    pub async fn listen_packet(
746        &self,
747        network: &str,
748        addr: &str,
749    ) -> Result<netstack::UdpSocket, Error> {
750        let net = dial::parse_network(network)?;
751        if net.transport != dial::Transport::Udp {
752            return Err(Error::Internal(InternalErrorKind::BadRequest));
753        }
754        let (host, port) = dial::split_host_port(addr)?;
755
756        // ListenPacket requires a valid IP host (Go rejects a name here).
757        let ip: IpAddr = host
758            .parse()
759            .map_err(|_| Error::Internal(InternalErrorKind::BadRequest))?;
760        dial::check_family(net.family, ip)?;
761
762        // A v6 bind (whether an explicit literal or an unspecified `[::]`) requires IPv6 to be
763        // provisioned — enforce the gate for BOTH cases (the unspecified `[::]` path used to skip it).
764        if ip.is_ipv6() && !self.enable_ipv6 {
765            return Err(Error::Internal(InternalErrorKind::BadRequest));
766        }
767
768        // An unspecified bind host (`0.0.0.0` / `[::]`) means "this node's tailnet address" — of the
769        // SAME family as the requested address, so a `udp6` `[::]:0` binds a v6 socket (it used to
770        // fall through to the v4 address regardless, silently yielding an IPv4 socket for a v6 listen).
771        let bind_ip: IpAddr = if ip.is_unspecified() {
772            if ip.is_ipv6() {
773                self.ipv6_addr().await?.into()
774            } else {
775                self.ipv4_addr().await?.into()
776            }
777        } else {
778            ip
779        };
780
781        self.udp_bind((bind_ip, port).into()).await
782    }
783
784    /// Connect to a TCP socket at the remote address.
785    ///
786    /// Returns an error in TUN transport mode (there is no application netstack to dial from).
787    pub async fn tcp_connect(&self, remote: SocketAddr) -> Result<netstack::TcpStream, Error> {
788        let channel = self.channel()?;
789
790        let ip: IpAddr = match remote.is_ipv4() {
791            true => self.ipv4_addr().await?.into(),
792            false => self.ipv6_addr().await?.into(),
793        };
794
795        // TODO(npry): collision checking
796        let ephemeral_port = rand::random_range(49152..=u16::MAX);
797
798        channel
799            .tcp_connect((ip, ephemeral_port).into(), remote)
800            .await
801            .map_err(Into::into)
802    }
803
804    /// Start a SOCKS5 proxy on a host loopback address that dials into the tailnet (Go
805    /// `tsnet.Server.Loopback`, SOCKS5 half).
806    ///
807    /// Binds a TCP listener on `127.0.0.1:0` (host loopback only — never an external interface) and
808    /// serves SOCKS5 (RFC 1928) with required username/password auth (RFC 1929): username `tsnet`,
809    /// password = the returned `proxy_cred`. Each `CONNECT` is dialed INTO the overlay via
810    /// [`Device::connect_by_name`] / [`Device::tcp_connect`] and spliced to the accepted host socket, so
811    /// a non-Rust host process can reach tailnet peers through the proxy. Returns the bound address, the
812    /// proxy credential, and a [`LoopbackHandle`] whose drop stops the listener.
813    ///
814    /// Anti-leak: the listener is loopback-only and every connection egresses over the overlay, never a
815    /// host socket — the host's real origin IP is never used to reach the destination. Unlike Go, the
816    /// LocalAPI HTTP surface is not served (this fork exposes status/whois/id-token natively on
817    /// `Device`); only the SOCKS5 proxy is provided.
818    ///
819    /// Returns an error in TUN transport mode (no application netstack to dial from).
820    pub async fn loopback(&self) -> Result<(std::net::SocketAddr, String, LoopbackHandle), Error> {
821        loopback::start(self.overlay_dialer().await?).await
822    }
823
824    /// Build an [`OverlayDialer`](loopback::OverlayDialer): the cloneable, `&Device`-free dialer that
825    /// resolves a MagicDNS name (or takes an IPv4 literal) and `tcp_connect`s it into the overlay,
826    /// reused by [`Device::loopback`] (SOCKS5) and the `hyper` [`http_connector`](Device::http_connector).
827    ///
828    /// Captures only cloneable pieces — never `&self` — so the dialer (and anything built on it, like a
829    /// spawned accept loop or an HTTP connector) carries no borrow of the `Device`: a clone of the
830    /// netstack command channel, this device's own overlay IPv4 (fetched once), and a boxed resolver
831    /// closure over clones of the control + peer-tracker actor refs. The resolver replicates
832    /// [`Device::resolve`] (peer-by-name, falling back to this node's own name).
833    async fn overlay_dialer(&self) -> Result<loopback::OverlayDialer, Error> {
834        let channel = self.channel()?.clone();
835        let self_ipv4 = self.ipv4_addr().await?;
836
837        let control = self.runtime.control.clone();
838        let peer_tracker = self.runtime.peer_tracker.clone();
839        let resolve: loopback::Resolver = std::sync::Arc::new(move |name: String| {
840            let control = control.clone();
841            let peer_tracker = peer_tracker.clone();
842            Box::pin(async move {
843                let pt = peer_tracker
844                    .upgrade()
845                    .ok_or(Error::Internal(InternalErrorKind::Actor))?;
846                let peer = pt
847                    .ask(ts_runtime::peer_tracker::PeerByName { name: name.clone() })
848                    .await
849                    .map_err(ts_runtime::Error::from)?;
850                if let Some(peer) = peer {
851                    return Ok(Some(peer.tailnet_address.ipv4.addr()));
852                }
853                // tsnet's dnsMap also resolves our own name; fall back to self.
854                let me = control
855                    .ask(ts_runtime::control_runner::SelfNode)
856                    .await
857                    .map_err(ts_runtime::Error::from)?
858                    .ok_or(Error::Internal(InternalErrorKind::Actor))?;
859                if me.matches_name(&name) {
860                    Ok(Some(me.tailnet_address.ipv4.addr()))
861                } else {
862                    Ok(None)
863                }
864            }) as std::pin::Pin<Box<dyn std::future::Future<Output = _> + Send>>
865        });
866
867        Ok(loopback::OverlayDialer::new(channel, self_ipv4, resolve))
868    }
869
870    /// Build a [`hyper`-compatible connector](crate::http::TailnetConnector) that routes outbound HTTP
871    /// requests over the tailnet — the analog of Go `tsnet.Server.HTTPClient`, whose mechanism is
872    /// simply `http.Transport{DialContext: s.Dial}`.
873    ///
874    /// Hand the returned connector to `hyper_util::client::legacy::Client::builder(...).build(conn)`;
875    /// each request's `Uri` host is resolved as a MagicDNS name (or IPv4 literal) and dialed into the
876    /// overlay (default port 80 for `http`, 443 for `https`), so the request egresses over the tailnet
877    /// rather than the host's network. TLS, redirects, and pooling are the hyper client's concern — the
878    /// connector only supplies the transport, exactly like Go's bare `DialContext` injection.
879    ///
880    /// Available only with the **`hyper`** crate feature.
881    ///
882    /// # Errors
883    /// Fails for the same reasons as [`Device::loopback`]'s setup: TUN transport mode (no application
884    /// netstack) or the node not yet having an overlay IPv4.
885    #[cfg(feature = "hyper")]
886    pub async fn http_connector(&self) -> Result<crate::http::TailnetConnector, Error> {
887        Ok(crate::http::TailnetConnector::new(
888            self.overlay_dialer().await?,
889        ))
890    }
891
892    /// Get our node info.
893    pub async fn self_node(&self) -> Result<NodeInfo, Error> {
894        self.runtime
895            .control
896            .ask(ts_runtime::control_runner::SelfNode)
897            .await
898            .map_err(ts_runtime::Error::from)?
899            .ok_or(Error::Internal(InternalErrorKind::Actor))
900    }
901
902    /// The DNS names this node can obtain TLS certificates for — Go `tsnet.Server.CertDomains()`.
903    ///
904    /// These are the `CertDomains` control pushed in the netmap DNS config: the names a TLS-serving
905    /// consumer (e.g. a `ListenTLS`/`GetCertificate`-style caller) should request a cert for. Returns
906    /// an empty `Vec` before the first netmap, or when control granted none — mirroring Go returning a
907    /// clone of `nm.DNS.CertDomains` (empty/`nil` when absent).
908    pub async fn cert_domains(&self) -> Result<Vec<String>, Error> {
909        self.runtime
910            .control
911            .ask(ts_runtime::control_runner::CertDomains)
912            .await
913            .map_err(ts_runtime::Error::from)
914            .map_err(Into::into)
915    }
916
917    /// The DNS configuration control pushed in the latest netmap — Go `tsnet`'s view of
918    /// `netmap.NetworkMap.DNS` (what `tailscale dns status` reports).
919    ///
920    /// Returns the full [`DnsConfig`] — MagicDNS on/off, search domains, global + fallback resolvers,
921    /// split-DNS routes, extra records, cert domains — or `None` before the first netmap / when
922    /// control has sent no DNS config. A superset of [`cert_domains`](Device::cert_domains), which
923    /// remains a separate narrower accessor for the TLS-cert use. Mirrors Go reading a clone of
924    /// `nm.DNS` (absent ⇒ `None`).
925    pub async fn dns_config(&self) -> Result<Option<DnsConfig>, Error> {
926        self.runtime
927            .control
928            .ask(ts_runtime::control_runner::DnsConfig)
929            .await
930            .map_err(ts_runtime::Error::from)
931            .map_err(Into::into)
932    }
933
934    /// The URL control last asked this node to open in a browser (`MapResponse.PopBrowserURL`), or
935    /// `None` if control has never sent one.
936    ///
937    /// This is the interactive-login / consent URL an embedder driving a non-authkey (interactive)
938    /// login must surface to the user — the Rust analog of Go `ipn` delivering `BrowseToURL` through
939    /// the notification bus. A daemon polls this after starting an interactive login to obtain the
940    /// auth URL to present.
941    ///
942    /// **Sticky semantics** (Go `controlclient`'s `sess.lastPopBrowserURL`): once control sends a
943    /// URL it remains the returned value until control sends a *different* non-empty one — it is
944    /// **never cleared back to `None`** (control sends `PopBrowserURL` empty on nearly every netmap
945    /// tick; those empty updates are ignored, not treated as "clear"). So a non-`None` result does
946    /// **not** signal "control is asking *right now*" vs. "already handled" — it is the last URL
947    /// seen this session. A consumer that acts on it should de-duplicate on the URL value rather than
948    /// re-acting on every poll. For a push stream of *new* consent URLs (rather than polling this
949    /// sticky value), subscribe to [`watch_ipn_bus`](Self::watch_ipn_bus) and react to
950    /// [`Notify::browse_to_url`](crate::Notify::browse_to_url).
951    pub async fn pop_browser_url(&self) -> Result<Option<Url>, Error> {
952        self.runtime
953            .control
954            .ask(ts_runtime::control_runner::PopBrowserUrl)
955            .await
956            .map_err(ts_runtime::Error::from)
957            .map_err(Into::into)
958    }
959
960    /// This node's latest network-conditions report — the Rust analog of Go's `netcheck.Report` as
961    /// `tailscale netcheck` surfaces it.
962    ///
963    /// Returns the [`NetcheckReport`]: the preferred (lowest-latency) DERP region and the per-region
964    /// latency map this node last measured. Empty (default) before the first measurement. This fork's
965    /// net-report path measures only DERP-region latency, so the report carries that subset rather
966    /// than fabricating the UDP/port-mapping fields Go also reports (see [`NetcheckReport`]).
967    pub async fn netcheck(&self) -> Result<NetcheckReport, Error> {
968        self.runtime
969            .control
970            .ask(ts_runtime::control_runner::Netcheck)
971            .await
972            .map_err(ts_runtime::Error::from)
973            .map_err(Into::into)
974    }
975
976    /// Suggest a reasonably good exit node to use, based on this node's current netmap and the DERP
977    /// latency it has measured recently — Go `tailscale exit-node suggest` /
978    /// `LocalBackend.SuggestExitNode`.
979    ///
980    /// Returns the suggested exit node's stable id + name as an [`ExitNodeSuggestion`]; engage it by
981    /// passing the id to [`Config::exit_node`](crate::config::Config) /
982    /// [`Device::set_exit_node`](crate::Device::set_exit_node) as a stable-id selector. The
983    /// suggestion uses the classic DERP-region-latency algorithm: among peers control marked
984    /// suggestable (the `suggest-exit-node` capability) that advertise an exit route and are online,
985    /// it prefers the one whose home DERP region this node measured as lowest-latency, and is
986    /// **sticky** — a prior suggestion that is still a good candidate is kept across calls, so
987    /// repeated calls don't flap between equally-good options.
988    ///
989    /// The ranking reads the *lowest latency seen per region over the recent measurement history*,
990    /// not the newest net-report alone. A single measurement of this node's DERP map ends as soon
991    /// as a few regions have answered, so a candidate homed further away would otherwise have no
992    /// measurement to be ranked on and the suggestion would degrade to a random pick among the
993    /// candidate regions.
994    ///
995    /// Outcomes (mirroring Go):
996    /// - `Ok(Some(suggestion))` — a node was suggested.
997    /// - `Ok(None)` — no eligible candidate (no suggestion); **not** an error.
998    /// - `Err(`[`Error::NoPreferredDerp`]`)` — no netcheck has completed yet, so no preferred DERP
999    ///   region is known; retry once connectivity has been measured.
1000    ///
1001    /// ## Scope (Phase 1)
1002    /// This ports Go's classic DERP path only. The traffic-steering path and the Mullvad
1003    /// geographic-distance ranking (for exit nodes with no DERP home) are not yet implemented, and
1004    /// the suggestion does not carry a `Location` (Go's `omitempty` field) — this fork's peer model
1005    /// has none yet. The candidate exit-route check accepts a peer advertising `0.0.0.0/0` (this
1006    /// fork is IPv4-only), rather than Go's both-`0.0.0.0/0`-and-`::/0` requirement.
1007    pub async fn suggest_exit_node(&self) -> Result<Option<ExitNodeSuggestion>, Error> {
1008        // The runtime returns the actor-gather outcome (outer) wrapping the algorithm outcome
1009        // (inner: `Ok(None)` empty, or the `NoPreferredDerp` domain error). Flatten both into the
1010        // device-facing `Error`.
1011        self.runtime.suggest_exit_node().await?.map_err(Into::into)
1012    }
1013
1014    /// This node's key-expiry instant as Unix seconds (`Node.KeyExpiry` in Go), or `Ok(None)` if
1015    /// the key never expires.
1016    ///
1017    /// Like Go, this fork is **reactive** about key expiry — it reports it rather than rotating the
1018    /// node key in the background. A caller can schedule re-authentication around this time; on
1019    /// expiry, re-create the [`Device`] (which re-registers), supplying a fresh node key + the prior
1020    /// `old_node_key` to rotate, or the same key to refresh.
1021    pub async fn self_key_expiry_unix(&self) -> Result<Option<i64>, Error> {
1022        Ok(self.self_node().await?.key_expiry_unix())
1023    }
1024
1025    /// Whether this node's key has expired as of now (`!KeyExpiry.IsZero() && KeyExpiry.Before(now)`
1026    /// in Go). A key with no expiry is never expired. See [`Device::self_key_expiry_unix`] for the
1027    /// reactive-rotation note.
1028    pub async fn self_key_expired(&self) -> Result<bool, Error> {
1029        let now = std::time::SystemTime::now()
1030            .duration_since(std::time::UNIX_EPOCH)
1031            .map(|d| d.as_secs() as i64)
1032            // An unreadable clock (pre-epoch) is treated as the far future so a time-limited key
1033            // looks expired — fail-safe toward prompting re-auth rather than trusting a stale key.
1034            .unwrap_or(i64::MAX);
1035        Ok(self.self_node().await?.key_expired_at_unix(now))
1036    }
1037
1038    /// Fetch the current Tailscale SSH policy pushed by control, if any.
1039    ///
1040    /// Returns `Ok(None)` when control has not sent an SSH policy. The SSH server treats an absent
1041    /// or empty policy as **deny-all** (fail-closed). Used by the SSH auth path
1042    /// ([`SshPolicy::evaluate`][ts_control::SshPolicy::evaluate]) to authorize incoming
1043    /// connections.
1044    pub async fn ssh_policy(&self) -> Result<Option<ts_control::SshPolicy>, Error> {
1045        self.runtime
1046            .control
1047            .ask(ts_runtime::control_runner::CurrentSshPolicy)
1048            .await
1049            .map_err(ts_runtime::Error::from)
1050            .map_err(Into::into)
1051    }
1052
1053    /// Look up a peer by name.
1054    pub async fn peer_by_name(&self, name: &str) -> Result<Option<NodeInfo>, Error> {
1055        let pt = self
1056            .runtime
1057            .peer_tracker
1058            .upgrade()
1059            .ok_or(Error::Internal(InternalErrorKind::Actor))?;
1060
1061        pt.ask(ts_runtime::peer_tracker::PeerByName {
1062            name: name.to_string(),
1063        })
1064        .await
1065        .map_err(ts_runtime::Error::from)
1066        .map_err(Into::into)
1067    }
1068
1069    /// Look up a peer by ip.
1070    pub async fn peer_by_tailnet_ip(&self, ip: IpAddr) -> Result<Option<NodeInfo>, Error> {
1071        let pt = self
1072            .runtime
1073            .peer_tracker
1074            .upgrade()
1075            .ok_or(Error::Internal(InternalErrorKind::Actor))?;
1076
1077        pt.ask(ts_runtime::peer_tracker::PeerByTailnetIp { ip })
1078            .await
1079            .map_err(ts_runtime::Error::from)
1080            .map_err(Into::into)
1081    }
1082
1083    /// Look up the current record for a peer by its stable node id, or `None` when the peer
1084    /// tracker holds no such peer (it has left the tailnet, or no netmap has arrived yet).
1085    ///
1086    /// Used to refresh a caller-held [`NodeInfo`] snapshot before acting on it — see
1087    /// [`Device::send_file`]. Unlike [`Device::peer_by_name`] this never waits for the first netmap:
1088    /// the caller has a snapshot to fall back on.
1089    async fn peer_by_stable_id(
1090        &self,
1091        stable_id: &ts_control::StableNodeId,
1092    ) -> Result<Option<NodeInfo>, Error> {
1093        let pt = self
1094            .runtime
1095            .peer_tracker
1096            .upgrade()
1097            .ok_or(Error::Internal(InternalErrorKind::Actor))?;
1098
1099        pt.ask(ts_runtime::peer_tracker::PeerByStableId {
1100            stable_id: stable_id.clone(),
1101        })
1102        .await
1103        .map_err(ts_runtime::Error::from)
1104        .map_err(Into::into)
1105    }
1106
1107    /// Look up the peer(s) with the most-specific route matches for `ip`.
1108    ///
1109    /// This reports which peers *advertise* a route covering `ip`, independent of this device's
1110    /// `accept_routes` setting — analogous to the Go client's informational `PrimaryRoutes`. It is
1111    /// not a reachability oracle: with `accept_routes` off, the dataplane will not actually route
1112    /// to (or accept return traffic from) advertised subnet routes even if this returns a peer.
1113    pub async fn peers_with_route(&self, ip: IpAddr) -> Result<Vec<NodeInfo>, Error> {
1114        let pt = self
1115            .runtime
1116            .peer_tracker
1117            .upgrade()
1118            .ok_or(Error::Internal(InternalErrorKind::Actor))?;
1119
1120        pt.ask(ts_runtime::peer_tracker::PeerByAcceptedRoute { ip })
1121            .await
1122            .map_err(ts_runtime::Error::from)
1123            .map_err(Into::into)
1124    }
1125
1126    /// List the Taildrop files this device has fully received and not yet consumed (Go LocalAPI
1127    /// `WaitingFiles`).
1128    ///
1129    /// Returns the files waiting under the configured `taildrop_dir`, sorted by name. Returns an
1130    /// empty list when Taildrop is disabled (`Config::taildrop_dir` unset) — fail-closed, never an
1131    /// error for the disabled case. A filesystem error while listing surfaces as
1132    /// [`InternalErrorKind::Actor`].
1133    pub fn taildrop_waiting_files(&self) -> Result<Vec<WaitingFile>, Error> {
1134        let Some(store) = self.runtime.taildrop_store() else {
1135            return Ok(Vec::new());
1136        };
1137        store
1138            .waiting_files()
1139            .map_err(|_| Error::Internal(InternalErrorKind::Actor))
1140    }
1141
1142    /// Open a received Taildrop file by name for reading, returning the handle and its size (Go
1143    /// LocalAPI `OpenFile`).
1144    ///
1145    /// The `name` is validated (path-traversal-safe) inside the store before any path is built.
1146    /// Returns [`InternalErrorKind::BadRequest`] when Taildrop is disabled or the name is invalid,
1147    /// and [`InternalErrorKind::Actor`] for a filesystem error (e.g. the file does not exist).
1148    pub fn taildrop_open_file(&self, name: &str) -> Result<(std::fs::File, u64), Error> {
1149        let store = self
1150            .runtime
1151            .taildrop_store()
1152            .ok_or(Error::Internal(InternalErrorKind::BadRequest))?;
1153        store.open_file(name).map_err(taildrop_err)
1154    }
1155
1156    /// Delete a received Taildrop file by name (Go LocalAPI `DeleteFile`).
1157    ///
1158    /// The `name` is validated (path-traversal-safe) inside the store before any path is built.
1159    /// Returns [`InternalErrorKind::BadRequest`] when Taildrop is disabled or the name is invalid,
1160    /// and [`InternalErrorKind::Actor`] for a filesystem error (e.g. the file does not exist).
1161    pub fn taildrop_delete_file(&self, name: &str) -> Result<(), Error> {
1162        let store = self
1163            .runtime
1164            .taildrop_store()
1165            .ok_or(Error::Internal(InternalErrorKind::BadRequest))?;
1166        store.delete_file(name).map_err(taildrop_err)
1167    }
1168
1169    /// Send a local file to a tailnet `peer` via Taildrop (Go `PushFile` / `tailscale file cp`).
1170    ///
1171    /// Pushes `content_length` bytes from `reader` to the peer's peerAPI as
1172    /// `PUT /v0/put/<name>` over the overlay netstack — the sending counterpart to the receive store
1173    /// surfaced by [`Device::taildrop_waiting_files`]. The transfer rides the encrypted WireGuard
1174    /// overlay, never a host socket. The body is streamed from offset 0 (no resume).
1175    ///
1176    /// The destination is derived **solely from `peer`'s own node record**
1177    /// ([`NodeInfo::peerapi_addr`][ts_control::Node::peerapi_addr]): its advertised tailnet IPv4 and
1178    /// `peerapi4` port. The caller obtains `peer` from [`Device::peer_by_name`] /
1179    /// [`Device::peer_by_tailnet_ip`], so it is always a current netmap peer — a raw control-supplied
1180    /// or attacker-chosen address can never be targeted. As defense in depth, the resolved address is
1181    /// additionally asserted to be a Tailscale CGNAT IP before dialing.
1182    ///
1183    /// `peer` is a **snapshot**: every lookup on this device hands out a clone, and a caller may
1184    /// have held it since before the last netmap. So the peer is re-read from the peer tracker by
1185    /// [`stable_id`][ts_control::Node::stable_id] first, and everything below — the expiry refusal
1186    /// and the destination — is decided on that current record; the snapshot is used only when the
1187    /// tracker no longer knows the peer at all.
1188    ///
1189    /// Returns [`Error::PeerKeyExpired`] when control has expired the peer's node key (Go refuses
1190    /// a peerAPI dial to such a peer with the same message);
1191    /// [`InternalErrorKind::BadRequest`] when the peer advertises no IPv4 peerAPI (so it
1192    /// cannot receive files), when the name is invalid, or when the peer refuses the transfer
1193    /// (`403`/`409`/unexpected status); [`Error::Timeout`] on a dial failure or timeout; and
1194    /// [`InternalErrorKind::Io`] on a mid-transfer stream error.
1195    pub async fn send_file<R>(
1196        &self,
1197        peer: &NodeInfo,
1198        name: &str,
1199        content_length: u64,
1200        reader: R,
1201    ) -> Result<(), Error>
1202    where
1203        R: tokio::io::AsyncRead + Unpin,
1204    {
1205        let channel = self.channel()?;
1206
1207        // Re-read the peer before anything is decided from it. `peer` is whatever the caller kept
1208        // from an earlier lookup, and a node's key expiry can pass with no netmap in between — the
1209        // peer tracker's own timer flags it. Deciding on the snapshot would miss that entirely and
1210        // dial a peer whose key this node has already broken, reporting the failed dial as a
1211        // timeout rather than the reason.
1212        let current = self.peer_by_stable_id(&peer.stable_id).await?;
1213        let dst = taildrop_dst(peer, current.as_ref())?;
1214
1215        let self_ipv4 = self.ipv4_addr().await?;
1216
1217        ts_runtime::taildrop_send::send_file(channel, self_ipv4, dst, name, content_length, reader)
1218            .await
1219            .map_err(taildrop_send_err)
1220    }
1221
1222    /// List the tailnet peers this node can Taildrop a file *to* — the Rust analog of Go's LocalAPI
1223    /// `FileTargets`.
1224    ///
1225    /// Each [`FileTarget`] pairs a peer's node record with the `http://ip:port` base of its peerAPI;
1226    /// pass `target.node` straight to [`Device::send_file`]. A peer qualifies when it advertises a
1227    /// reachable IPv4 peerAPI **and** is either owned by the same user as this node **or** explicitly
1228    /// granted the file-sharing-target capability — mirroring upstream's send-path filter. The list is
1229    /// gated on this node holding the file-sharing capability (control grants it when the admin
1230    /// enables Taildrop); absent that, the result is empty (fail-closed, not an error). Sorted by the
1231    /// peer's MagicDNS name. Targets are listed regardless of online state (matching upstream — an
1232    /// offline target's [`send_file`](Device::send_file) simply times out). Empty before the first
1233    /// netmap.
1234    pub async fn file_targets(&self) -> Result<Vec<FileTarget>, Error> {
1235        self.runtime.file_targets().await.map_err(Into::into)
1236    }
1237
1238    /// Begin a debug packet capture, streaming a pcap of every packet crossing the dataplane to
1239    /// `writer` (Go `tsnet.Server.CapturePcap`).
1240    ///
1241    /// Installs a capture hook on the running dataplane: from now until [`Device::stop_capture`] is
1242    /// called (or another capture replaces this one), a copy of every plaintext IP packet on the
1243    /// datapath — outbound (pre-encrypt) and inbound (post-decrypt) — is framed and written to
1244    /// `writer`. The 24-byte pcap global header is written immediately on success.
1245    ///
1246    /// The format is byte-faithful classic pcap with Tailscale's `LINKTYPE_USER0` + 4-byte path
1247    /// preamble per record (see [`ts_runtime::capture`]); a resulting file opens in Wireshark, and
1248    /// with Tailscale's `ts-dissector.lua` the direction/path of each packet decodes.
1249    ///
1250    /// The hook runs **inline on the single-threaded dataplane step**, so `writer` must not block for
1251    /// long — a slow writer back-pressures the datapath. Records are **not** flushed per packet (that
1252    /// would be a syscall on every packet on the dataplane thread); buffered bytes are flushed when
1253    /// the writer is dropped on [`Device::stop_capture`]. Wrap `writer` in a [`std::io::BufWriter`] if
1254    /// you want buffering. A write error is swallowed per-packet (the capture silently drops that
1255    /// record) rather than tearing down the datapath; call [`Device::stop_capture`] to end it. Returns
1256    /// an error only if the dataplane actor is unreachable or the initial global-header write fails.
1257    pub async fn capture_pcap<W>(&self, writer: W) -> Result<(), Error>
1258    where
1259        W: std::io::Write + Send + 'static,
1260    {
1261        let sink = std::sync::Arc::new(std::sync::Mutex::new(
1262            ts_runtime::capture::PcapSink::new(writer)
1263                .map_err(|_| Error::Internal(InternalErrorKind::Io))?,
1264        ));
1265        let hook: ts_runtime::CaptureHook = std::sync::Arc::new(move |path, pkt: &[u8]| {
1266            if let Ok(mut sink) = sink.lock() {
1267                // A per-packet write failure (e.g. a closed pipe) silently drops that record rather
1268                // than tearing down the datapath; the caller ends capture via `stop_capture`.
1269                drop(sink.log_packet(path.code(), pkt));
1270            }
1271        });
1272        self.runtime.install_capture(Some(hook)).await?;
1273        Ok(())
1274    }
1275
1276    /// Stop a debug packet capture started by [`Device::capture_pcap`] (Go `ClearCaptureSink`).
1277    ///
1278    /// Clears the dataplane capture hook; the writer is dropped (its remaining buffered bytes are
1279    /// flushed by its own `Drop`). Idempotent — clearing when no capture is installed is a no-op.
1280    /// Returns an error only if the dataplane actor is unreachable.
1281    pub async fn stop_capture(&self) -> Result<(), Error> {
1282        self.runtime.install_capture(None).await?;
1283        Ok(())
1284    }
1285
1286    /// Snapshot of this device and its tailnet peers (like `tailscale status`).
1287    ///
1288    /// Combines this node's self info with the current peer set: each [`StatusNode`] reports the
1289    /// stable id, display name, tailnet IPs, advertised routes, and exit-node flag. (Per-peer
1290    /// `online`/user/capabilities are honestly `None`/empty in this fork — the domain node model
1291    /// does not yet carry the wire-level liveness/login fields; see `ts_runtime::status` docs.)
1292    pub async fn status(&self) -> Result<Status, Error> {
1293        self.runtime.status().await.map_err(Into::into)
1294    }
1295
1296    /// Fetch the current Tailnet Lock (TKA) status pushed by control, if any.
1297    ///
1298    /// Returns `Ok(None)` when control has sent no `TKAInfo` (tailnet lock not in use, or no change
1299    /// observed yet). The returned [`TkaStatus`][ts_control::TkaStatus] carries the authority head
1300    /// (a base32 `AUMHash`, decode with [`tka::AumHash::from_base32`][ts_tka::AumHash::from_base32])
1301    /// and the disablement signal. Signature verification of a peer's node-key signature against the
1302    /// authority is performed with the [`tka`] module's [`tka::Authority`].
1303    pub async fn tka_status(&self) -> Result<Option<ts_control::TkaStatus>, Error> {
1304        self.runtime
1305            .control
1306            .ask(ts_runtime::control_runner::CurrentTkaStatus)
1307            .await
1308            .map_err(ts_runtime::Error::from)
1309            .map_err(Into::into)
1310    }
1311
1312    /// Read the Tailnet Lock update-chain history — the Rust analog of Go
1313    /// `LocalClient.NetworkLockLog`.
1314    ///
1315    /// Returns up to `limit` [`TkaLogEntry`] rows of the AUM chain **head-first** (newest first,
1316    /// walking back toward the genesis), read **locally** from this node's synced + verified chain —
1317    /// a pure read with no control round-trip. The list is empty when no lock is synced (lock not in
1318    /// use, or control hasn't pushed a chain yet). Each entry carries the AUM's chain-link hash, its
1319    /// change kind (`"add-key"` / `"remove-key"` / `"checkpoint"` / …), the ids of the keys that
1320    /// signed it, and the raw CBOR (Go `NetworkLockUpdate.Raw`) for a faithful full decode.
1321    pub async fn tka_log(&self, limit: usize) -> Result<Vec<TkaLogEntry>, Error> {
1322        self.runtime.tka_log(limit).await.map_err(Into::into)
1323    }
1324
1325    /// Sign a peer's `node_key` with this node's network-lock key and submit the signature to
1326    /// control — the Rust analog of Go `LocalClient.NetworkLockSign` for the Direct case.
1327    ///
1328    /// Builds a `Direct` [`NodeKeySignature`][ts_tka::NodeKeySignature] authorizing `node_key`, signed
1329    /// by this node's network-lock private key, and POSTs it to `/machine/tka/sign`. The signing node
1330    /// must itself be trusted under the current authority for control to accept the signature.
1331    ///
1332    /// **This only *submits* the signature; it does not mutate this node's local
1333    /// [`Authority`][ts_tka::Authority].** The local trusted-key state advances solely through the
1334    /// verified netmap-driven sync path (every applied AUM passes
1335    /// [`VerifiedAumChain::verify`][ts_tka::VerifiedAumChain::verify]), so a successful `tka_sign` is
1336    /// reflected locally on the next sync — the active fail-closed enforcement posture is unchanged.
1337    ///
1338    /// # Errors
1339    /// [`ts_control::TkaSyncError::Unsupported`] if control has no TKA endpoint (no lock / control too
1340    /// old), [`ts_control::TkaSyncError::NetworkError`] on a transient failure, or a coarse
1341    /// `Internal` for other RPC failures.
1342    pub async fn tka_sign(
1343        &self,
1344        node_key: &ts_keys::NodePublicKey,
1345    ) -> Result<(), ts_control::TkaSyncError> {
1346        self.runtime.tka_sign(node_key.to_bytes()).await
1347    }
1348
1349    /// Disable Tailnet Lock by presenting the `disablement_secret` to control — the Rust analog of
1350    /// Go `LocalClient.NetworkLockDisable`.
1351    ///
1352    /// Targets this node's current authority head (from the cached [`tka_status`](Device::tka_status));
1353    /// the `disablement_secret` is the operator-held capability (one of the lock's
1354    /// `DisablementValues`) that authorizes turning the lock off. Control verifies the secret against
1355    /// the authority's disablement set and, if valid, disables the lock for the tailnet.
1356    ///
1357    /// **Submit-only:** this POSTs the disablement; it does not mutate this node's local
1358    /// [`Authority`][ts_tka::Authority]. The disablement is reflected locally through the existing
1359    /// verified netmap-driven sync — which then clears enforcement to admit-all. The active
1360    /// fail-closed enforcement posture (until that sync lands) is unchanged.
1361    ///
1362    /// # Errors
1363    /// [`ts_control::TkaSyncError::Unsupported`] when there is no known TKA head to disable (lock not
1364    /// in use / control hasn't pushed a status) or control has no TKA endpoint;
1365    /// [`ts_control::TkaSyncError::NetworkError`] on a transient failure; a coarse `Internal` for
1366    /// other RPC failures (incl. control rejecting an invalid secret).
1367    pub async fn tka_disable(
1368        &self,
1369        disablement_secret: Vec<u8>,
1370    ) -> Result<(), ts_control::TkaSyncError> {
1371        self.runtime.tka_disable(disablement_secret).await
1372    }
1373
1374    /// Initialize Tailnet Lock for this tailnet with this node as the sole initial trusted key — the
1375    /// Rust analog of Go `LocalClient.NetworkLockInit` for the single-node "lock yourself in" case.
1376    ///
1377    /// Builds and signs a genesis Checkpoint AUM trusting only this node's network-lock key and
1378    /// gated by `disablement_secret` (stored as its Argon2i [`disablement_value`][ts_tka::disablement_value]
1379    /// in the lock; the raw secret is the operator-held capability that later disables it via
1380    /// [`tka_disable`](Device::tka_disable)), then drives control's two-phase
1381    /// `/machine/tka/init/{begin,finish}`.
1382    ///
1383    /// **Single-node only (for now):** if control reports that other nodes must be (re)signed under
1384    /// the new lock (a multi-node tailnet), this returns [`ts_control::TkaSyncError::Unsupported`] —
1385    /// the multi-node init (re-signing each node, incl. rotation keys) is a deferred follow-up.
1386    ///
1387    /// **Submit-only:** this creates the lock at control and does not seed this node's local
1388    /// [`Authority`][ts_tka::Authority]; the lock is reflected locally through the verified
1389    /// netmap-driven sync (every applied AUM passes
1390    /// [`VerifiedAumChain::verify`][ts_tka::VerifiedAumChain::verify]). Verify-and-log posture is
1391    /// unchanged.
1392    ///
1393    /// # Errors
1394    /// [`ts_control::TkaSyncError::Unsupported`] if control has no TKA endpoint or requires re-signing
1395    /// other nodes; [`ts_control::TkaSyncError::NetworkError`] on a transient failure; a coarse
1396    /// `Internal` for a malformed genesis or other RPC failure (incl. control rejecting the init,
1397    /// e.g. a lock already exists).
1398    pub async fn tka_init(
1399        &self,
1400        disablement_secret: Vec<u8>,
1401    ) -> Result<(), ts_control::TkaSyncError> {
1402        self.runtime.tka_init(disablement_secret).await
1403    }
1404
1405    /// Request an OIDC **ID token** from control for this node, scoped to `audience` (workload-
1406    /// identity federation, like `tailscale`'s `id-token` LocalAPI).
1407    ///
1408    /// Returns a signed JWT whose `sub` claim is this node's MagicDNS name and whose `aud` claim is
1409    /// `audience`, suitable for presenting to a third-party relying party (e.g. AWS/GCP
1410    /// workload-identity federation). The node is the token *subject*, not the authenticator — this
1411    /// is token issuance over the Noise transport (`POST /machine/id-token`), not a login path.
1412    /// Requires the control plane to support capability version ≥ 30.
1413    pub async fn fetch_id_token(&self, audience: &str) -> Result<String, ts_control::IdTokenError> {
1414        self.runtime.fetch_id_token(audience.to_string()).await
1415    }
1416
1417    /// Publish a `TXT` DNS record for this node into the tailnet's `ts.net` zone via control's
1418    /// `/machine/set-dns` RPC — the Rust analog of Go `tailscale.com/client/tailscale`'s
1419    /// `LocalClient.SetDNS(ctx, name, value)`.
1420    ///
1421    /// `name` is the full record name (e.g. `_acme-challenge.host.tailnet.ts.net`) and `value` is
1422    /// the record value (e.g. the base64url DNS-01 digest). Like Go's `SetDNS`, this publishes a
1423    /// `TXT` record specifically — its canonical use is satisfying an ACME DNS-01 challenge so a CA
1424    /// can verify control of a `*.ts.net` name. Issuance over the Noise transport (`POST
1425    /// /machine/set-dns`), not a login path.
1426    pub async fn set_dns(&self, name: &str, value: &str) -> Result<(), ts_control::SetDnsError> {
1427        self.runtime
1428            .set_dns(name.to_string(), value.to_string())
1429            .await
1430    }
1431
1432    /// Log this node out of the tailnet — deregister it from the control plane (the equivalent of
1433    /// Go `tsnet`'s `LocalClient.Logout`).
1434    ///
1435    /// Re-`POST`s `/machine/register` with this node's current node key and a past expiry, which the
1436    /// control plane honors by **expiring the node now**: it drops out of every peer's netmap and
1437    /// must re-register (re-authenticate) to rejoin.
1438    ///
1439    /// This is primarily for **non-ephemeral** nodes. An ephemeral node is garbage-collected by
1440    /// control shortly after it disconnects, but a persistent node lingers in the tailnet
1441    /// (visible to peers, counting against the machine limit) for up to ~24h after the process exits
1442    /// unless explicitly logged out. Call this before [`shutdown`](Self::shutdown) to deregister
1443    /// immediately. Calling it on an ephemeral node simply brings the GC forward; it is idempotent,
1444    /// so logging out an already-gone node is not an error.
1445    ///
1446    /// This is a **control-plane state change only**: it does not tear down the local datapath (do
1447    /// that via [`shutdown`](Self::shutdown)), and it does not delete or rotate the on-disk node key
1448    /// — re-registering with the same key (a fresh [`Device::new`]) is the re-login path.
1449    pub async fn logout(&self) -> Result<(), ts_control::LogoutError> {
1450        self.runtime.logout().await
1451    }
1452
1453    /// Snapshot this node's client metrics in Prometheus text exposition format.
1454    ///
1455    /// Mirrors Go Tailscale's `clientmetric` registry: process-global counters/gauges incremented
1456    /// on the datapath hot loops (e.g. `magicsock_send_udp`, `magicsock_recv_data_bytes_udp`),
1457    /// rendered as `# TYPE <name> <kind>\n<name> <value>\n` per metric, sorted by name. (Go `tsnet`
1458    /// exposes no metrics method of its own, so this is the fork's clean public surface.) The
1459    /// registry is process-global, so the output covers every `Device` in the process.
1460    pub fn metrics(&self) -> String {
1461        ts_metrics::write_prometheus()
1462    }
1463
1464    /// Map a tailnet source `addr` to the node that owns its IP (like `tsnet`'s `WhoIs`).
1465    ///
1466    /// Only the IP of `addr` is used; the port is ignored. Returns `Ok(None)` if no tailnet node
1467    /// owns that address.
1468    ///
1469    /// This is how an application authorises an inbound tailnet connection by who opened it. The
1470    /// result carries the owning user's whole profile ([`WhoIs::user_profile`]), including the
1471    /// group membership control reported ([`WhoIs::user_groups`]) — the one owner attribute the
1472    /// node cannot re-derive from the rest of the netmap.
1473    pub async fn whois(&self, addr: SocketAddr) -> Result<Option<WhoIs>, Error> {
1474        self.runtime.whois(addr).await.map_err(Into::into)
1475    }
1476
1477    /// Change the selected exit node at runtime, without recreating the [`Device`] — the equivalent
1478    /// of Go `tsnet`'s `LocalClient.EditPrefs(ExitNodeID/ExitNodeIP)`.
1479    ///
1480    /// The peer may be named by stable node ID, tailnet IP, or MagicDNS name via
1481    /// [`ExitNodeSelector`] (a bare IP or name parses with `selector.parse()`); this is the same
1482    /// selector type as [`Config::exit_node`](crate::Config::exit_node), so the construction-time
1483    /// and runtime paths are identical. Passing `None` clears the exit node — internet-bound traffic
1484    /// is then dropped (fail-closed) unless this node egresses directly.
1485    ///
1486    /// The change is applied immediately: the new selector is re-resolved against the live peer set
1487    /// and the outbound route + inbound source filter are recomputed at once. A selector for a peer
1488    /// not yet in the netmap simply takes effect once that peer appears.
1489    ///
1490    /// Only NEW flows use the changed exit; in-flight connections are not torn down and continue
1491    /// egressing via the previously-selected exit until they close.
1492    pub async fn set_exit_node(&self, exit_node: Option<ExitNodeSelector>) -> Result<(), Error> {
1493        self.runtime
1494            .set_exit_node(exit_node)
1495            .await
1496            .map_err(Into::into)
1497    }
1498
1499    /// The currently-selected exit node, or `None` if none is selected.
1500    pub fn exit_node(&self) -> Option<ExitNodeSelector> {
1501        self.runtime.exit_node()
1502    }
1503
1504    /// Toggle whether this node accepts peer-advertised subnet routes at runtime, without recreating
1505    /// the [`Device`] — the equivalent of Go `tsnet`'s `LocalClient.EditPrefs(RouteAll)` /
1506    /// `tailscale set --accept-routes`.
1507    ///
1508    /// This is a purely **local** preference: unlike [`set_advertise_routes`](Self::set_advertise_routes)
1509    /// it is never reported to control, so it only changes which peer-advertised subnet routes *this*
1510    /// node installs. The change is applied immediately — the outbound route table and the inbound
1511    /// source filter are recomputed together against the live peer set, so turning it on installs (and
1512    /// accepts traffic from) newly-accepted subnets and turning it off removes them from both in
1513    /// lock-step. A peer's own tailnet address is always reachable regardless; the exit-node default
1514    /// route is governed by [`set_exit_node`](Self::set_exit_node), not this flag.
1515    ///
1516    /// Only NEW flows are affected; in-flight connections are not torn down. In TUN transport mode the
1517    /// netstack data path honors the toggle immediately, but the host routing table is not re-steered
1518    /// until the device is rebuilt.
1519    pub async fn set_accept_routes(&self, accept: bool) -> Result<(), Error> {
1520        self.runtime
1521            .set_accept_routes(accept)
1522            .await
1523            .map_err(Into::into)
1524    }
1525
1526    /// Whether this node currently accepts peer-advertised subnet routes (`--accept-routes`).
1527    pub fn accept_routes(&self) -> bool {
1528        self.runtime.accept_routes()
1529    }
1530
1531    /// Toggle whether this node accepts the tailnet's DNS configuration at runtime, without
1532    /// recreating the [`Device`] — the equivalent of Go `tsnet`'s `LocalClient.EditPrefs(CorpDNS)` /
1533    /// `tailscale set --accept-dns`.
1534    ///
1535    /// Like [`set_accept_routes`](Self::set_accept_routes) this is a purely **local** preference,
1536    /// never reported to control. When `false`, the MagicDNS responder ignores the control-pushed DNS
1537    /// configuration and answers every query `REFUSED` (mirroring Go applying an empty `dns.Config`
1538    /// when `CorpDNS` is off), so the node can join the tailnet for connectivity without taking over
1539    /// its DNS. The change is applied immediately to the netstack responder and the peerAPI DoH server
1540    /// that shares its view; flipping it back to `true` restores serving from the still-current config
1541    /// (the config is only gated at the read site, never destroyed), so the OFF→ON restore is
1542    /// automatic.
1543    ///
1544    /// In TUN transport mode the in-datapath responder honors the toggle immediately, but the host
1545    /// resolver/route programming (which points the host at `100.100.100.100`) is applied once at
1546    /// device build and is not re-steered until the device is rebuilt.
1547    pub async fn set_accept_dns(&self, accept: bool) -> Result<(), Error> {
1548        self.runtime
1549            .set_accept_dns(accept)
1550            .await
1551            .map_err(Into::into)
1552    }
1553
1554    /// Whether this node currently accepts the tailnet's DNS configuration (`--accept-dns` / `CorpDNS`).
1555    pub fn accept_dns(&self) -> bool {
1556        self.runtime.accept_dns()
1557    }
1558
1559    /// Change the subnet routes this node advertises at runtime — Go `tailscale set
1560    /// --advertise-routes`. This is the runtime equivalent of
1561    /// [`Config::advertise_routes`](crate::Config::advertise_routes): the node re-advertises the
1562    /// prefixes to control (so it is granted the subnet-router role for them) AND starts forwarding
1563    /// them on the data path, applied together so the two never disagree.
1564    ///
1565    /// `routes` is filtered to the IPv4-only, deduplicated set this fork honors (IPv6 prefixes are
1566    /// dropped under the IPv6-off posture). This sets the explicit subnet prefixes only; it does not
1567    /// affect the exit-node `0.0.0.0/0` advertisement. Only NEW forwarded flows use the changed set;
1568    /// in-flight flows keep their existing routing until they close.
1569    pub async fn set_advertise_routes(&self, routes: Vec<ipnet::IpNet>) -> Result<(), Error> {
1570        self.runtime
1571            .set_advertise_routes(routes)
1572            .await
1573            .map_err(Into::into)
1574    }
1575
1576    /// Advertise (or stop advertising) this node as an **exit node** at runtime — Go `tailscale set
1577    /// --advertise-exit-node`. The runtime equivalent of
1578    /// [`Config::advertise_exit_node`](crate::Config::advertise_exit_node): when `enable` it adds the
1579    /// `0.0.0.0/0` default route to what this node advertises (and forwards), when `false` it removes
1580    /// it.
1581    ///
1582    /// Composes with [`set_advertise_routes`](Device::set_advertise_routes): the explicit subnet
1583    /// routes and the exit-node advertisement are independent — toggling one preserves the other.
1584    /// Advertising an exit node only makes this node *eligible*; control + the peer still decide
1585    /// whether to route through it. Only NEW forwarded flows see the change; in-flight flows keep
1586    /// their routing.
1587    pub async fn set_advertise_exit_node(&self, enable: bool) -> Result<(), Error> {
1588        self.runtime
1589            .set_advertise_exit_node(enable)
1590            .await
1591            .map_err(Into::into)
1592    }
1593
1594    /// Change this node's hostname at runtime — Go `tailscale set --hostname`. Re-reports
1595    /// `Hostinfo.Hostname` to control on the live connection (no rebuild, no reconnect); control
1596    /// reflects the new name in the netmap (it drives the node's MagicDNS name / `tailscale status`
1597    /// display). Hostname is display metadata, so there is no data-path effect. The new value also
1598    /// persists across a later re-registration.
1599    pub async fn set_hostname(&self, hostname: String) -> Result<(), Error> {
1600        self.runtime
1601            .set_hostname(hostname)
1602            .await
1603            .map_err(Into::into)
1604    }
1605
1606    /// Re-bind the underlay UDP socket after a **network/link change** — Wi-Fi switch, sleep/wake,
1607    /// or any event that invalidates the device's local address/NAT mapping. This is the Rust
1608    /// analog of Go magicsock's `Conn.Rebind()`.
1609    ///
1610    /// The embedder owns deciding *when* to call this (it watches the OS for link changes — there is
1611    /// no built-in network monitor); `rebind` is the engine half that does the socket work:
1612    /// - Re-binds the underlay UDP socket, preferring the same local port (so the advertised
1613    ///   endpoint stays stable) and falling back to an ephemeral port. The IPv4-only-by-default
1614    ///   invariant is preserved.
1615    /// - Invalidates the now-stale local mapping: learned reflexive (STUN) addresses and every
1616    ///   peer's *confirmed* direct path are cleared, while candidate endpoints are kept — so peers
1617    ///   are re-probed over the new socket and **relay over DERP (never a direct host dial) until a
1618    ///   path re-confirms**. Endpoint discovery re-runs on its normal cadence.
1619    /// - Leaves peers, control, the netmap, disco keys, and DERP connections untouched; existing
1620    ///   WireGuard sessions survive (they ride whatever underlay carries them).
1621    ///
1622    /// A no-op if the underlay socket failed to bind at startup (the device is DERP-only). Existing
1623    /// connectivity is preserved on a re-bind error (the old socket is kept; the error is returned).
1624    pub async fn rebind(&self) -> Result<(), Error> {
1625        self.runtime.rebind().await.map_err(Into::into)
1626    }
1627
1628    /// Force an immediate STUN re-probe / endpoint re-derivation **without** rebinding the underlay
1629    /// socket — the Rust analog of Go magicsock's `Conn.ReSTUN("debug")` (what `tailscale debug
1630    /// restun` triggers).
1631    ///
1632    /// Unlike [`rebind`](Self::rebind), this does **not** swap the socket or disturb any learned
1633    /// path: it keeps the existing UDP socket and its NAT mapping and only re-runs the STUN sweep
1634    /// now (re-learning this node's reflexive/public address) instead of waiting out the periodic
1635    /// (~23s, jittered) prober. Use it when this node's public endpoint may have changed (e.g. a NAT
1636    /// rebinding) but the socket itself is still fine — it is strictly lighter than a rebind.
1637    ///
1638    /// Peers, control, the netmap, disco keys, and DERP are untouched, and there is **no control
1639    /// round-trip**. A no-op if the underlay socket failed to bind at startup (the device is
1640    /// DERP-only) or while no peer is configured (matching the periodic prober's gate).
1641    pub async fn re_stun(&self) -> Result<(), Error> {
1642        self.runtime.re_stun().await.map_err(Into::into)
1643    }
1644
1645    /// The stable id of the exit node traffic is **currently** egressing through, or `None` if none
1646    /// is engaged (the equivalent of Go `tsnet`'s `Status.ExitNodeStatus.ID`).
1647    ///
1648    /// This differs from [`exit_node`](Self::exit_node), which returns the *configured* selector:
1649    /// the active exit node is the route updater's resolved, fail-closed answer. It is `None` when
1650    /// no exit node is configured, the configured selector matches no current peer, or the matched
1651    /// peer no longer advertises a default route (egress is then dropped, fail-closed). Match the id
1652    /// against [`Status::peers`](crate::Status::peers) (via [`status`](Self::status)) for details.
1653    pub fn active_exit_node(&self) -> Option<ts_control::StableNodeId> {
1654        self.runtime.active_exit_node()
1655    }
1656
1657    /// Watch for netmap changes: the returned receiver's value is the current set of peer
1658    /// [`StatusNode`]s and updates on every netmap change. This is the narrow peer-only view; for
1659    /// the unified Go-`WatchIPNBus` feed (peers + device-state + login URL in one stream) use
1660    /// [`watch_ipn_bus`](Self::watch_ipn_bus).
1661    pub async fn watch_netmap(
1662        &self,
1663    ) -> Result<tokio::sync::watch::Receiver<Vec<StatusNode>>, Error> {
1664        self.runtime.watch_netmap().await.map_err(Into::into)
1665    }
1666
1667    /// The current device connection-[`DeviceState`] (`Connecting` / `Running` / `NeedsLogin` /
1668    /// `Expired` / `Failed`).
1669    pub fn device_state(&self) -> DeviceState {
1670        self.runtime.device_state()
1671    }
1672
1673    /// Watch the device connection-[`DeviceState`], reacting push-style to control connection
1674    /// transitions instead of polling [`status`](Self::status).
1675    ///
1676    /// Returns a [`tokio::sync::watch::Receiver`]; await its
1677    /// [`changed`](tokio::sync::watch::Receiver::changed) to be woken on each transition. The
1678    /// initial value is the current state.
1679    pub fn watch_state(&self) -> tokio::sync::watch::Receiver<DeviceState> {
1680        self.runtime.watch_state()
1681    }
1682
1683    /// Subscribe to the unified IPN notification bus (Go `ipn`'s `WatchIPNBus`).
1684    ///
1685    /// Returns an [`IpnBusWatcher`]; await [`next`](IpnBusWatcher::next) to receive [`Notify`]
1686    /// events that merge device-[`DeviceState`] transitions (with the interactive-login URL surfaced
1687    /// as [`Notify::browse_to_url`]) and netmap peer-set changes into one feed — the single stream a
1688    /// consumer porting from Go's `WatchNotifications` expects, instead of composing
1689    /// [`watch_state`](Self::watch_state) and [`watch_netmap`](Self::watch_netmap) by hand. `mask`
1690    /// ([`NotifyWatchOpt`]) front-loads the current state as an initial snapshot on subscribe
1691    /// (`INITIAL_STATE` / `INITIAL_NETMAP`), mirroring Go's `NotifyInitialState` /
1692    /// `NotifyInitialNetMap`. Delivery is best-effort (a slow consumer drops notifications rather
1693    /// than stalling the runtime); the stream ends when the device shuts down.
1694    pub async fn watch_ipn_bus(&self, mask: NotifyWatchOpt) -> Result<IpnBusWatcher, Error> {
1695        self.runtime.watch_ipn_bus(mask).await.map_err(Into::into)
1696    }
1697
1698    /// Wait until the device finishes registering, returning a typed outcome — the clean
1699    /// replacement for polling [`ipv4_addr`](Self::ipv4_addr) in a loop.
1700    ///
1701    /// Resolves `Ok(())` once the device is [`DeviceState::Running`]. On a non-running outcome it
1702    /// returns a typed [`RegistrationError`]:
1703    /// - [`AuthRejected`](RegistrationError::AuthRejected) — bad/expired/unknown auth key;
1704    ///   **permanent** (re-pair).
1705    /// - [`NeedsLogin`](RegistrationError::NeedsLogin) — interactive authorization required;
1706    ///   **not permanent** (the runtime keeps retrying and reaches `Running` once the user
1707    ///   authorizes). Auth-key callers treat this as failure; interactive callers should ignore it
1708    ///   and drive the flow via [`watch_state`](Self::watch_state).
1709    /// - [`NetworkUnreachable`](RegistrationError::NetworkUnreachable) — **transient** (retry).
1710    /// - [`Timeout`](RegistrationError::Timeout) — no settled state within `timeout` (`None` waits
1711    ///   indefinitely).
1712    ///
1713    /// [`KeyExpired`](RegistrationError::KeyExpired) is not produced here (a key expires only after
1714    /// the node is up); observe it via [`watch_state`](Self::watch_state). Use
1715    /// [`RegistrationError::is_permanent`] to branch "re-pair" vs. "retry / drive login".
1716    pub async fn wait_until_running(
1717        &self,
1718        timeout: Option<Duration>,
1719    ) -> Result<(), RegistrationError> {
1720        self.runtime.wait_until_running(timeout).await
1721    }
1722
1723    /// Ping a tailnet peer over the overlay with an ICMPv4 echo, returning the round-trip time
1724    /// (like `tailscale ping`).
1725    ///
1726    /// The echo is sent from this device's own tailnet IPv4 over the overlay netstack — never a
1727    /// host socket. IPv6 destinations return [`PingError::Ipv6Unsupported`] (this fork is
1728    /// IPv4-only on the tailnet). A peer answers from its own OS stack; this netstack does not
1729    /// auto-reply to echo requests.
1730    ///
1731    /// In TUN transport mode there is no application netstack to ping from; this surfaces as
1732    /// [`PingError::Timeout`] (the same error this method already uses for an unavailable source
1733    /// address — `PingError` carries no dedicated "unsupported" variant).
1734    pub async fn ping(&self, dst: IpAddr, timeout: Duration) -> Result<Duration, PingError> {
1735        let channel = self.channel().map_err(|_| PingError::Timeout)?;
1736        let src = self.ipv4_addr().await.map_err(|_| PingError::Timeout)?;
1737        ts_netstack_smoltcp::ping(channel, src, dst, timeout).await
1738    }
1739
1740    /// The current **direct path** to the peer at tailnet IP `dst`: its confirmed direct UDP
1741    /// endpoint and that path's last-measured round-trip latency, or `None` when traffic to the peer
1742    /// is **relayed via DERP** (no trusted direct path right now), the peer is unknown, or it has no
1743    /// disco key.
1744    ///
1745    /// This is the direct-path analog of Go's `tailscale ping`/`PeerStatus` connectivity: a present
1746    /// result means packets reach the peer directly at the returned address, with roughly the
1747    /// returned RTT. The latency is a live snapshot taken from the most recent disco ping/pong that
1748    /// confirmed the path (up to one probe interval stale) — not a fresh on-demand round-trip. Unlike
1749    /// [`ping`](Device::ping) (an ICMP echo over the netstack), this reports the *underlay* path the
1750    /// data plane actually uses, distinguishing a direct connection from a DERP-relayed one.
1751    pub async fn direct_path(&self, dst: IpAddr) -> Result<Option<(SocketAddr, Duration)>, Error> {
1752        self.runtime.direct_path(dst).await.map_err(Into::into)
1753    }
1754
1755    /// Send a disco ping to the peer at tailnet IP `dst` **now** and await the pong — a fresh,
1756    /// on-demand round-trip measurement (Go's `tailscale ping`, `PingType::Disco`). Returns the
1757    /// endpoint that answered and the measured RTT, or `None` if no pong arrives within `timeout`
1758    /// (or the peer is unknown / has no candidate direct path).
1759    ///
1760    /// Unlike [`direct_path`](Device::direct_path) — which reports the *last periodic probe's* RTT
1761    /// from cache — this actively sends a ping and waits for the reply, so the latency is current. A
1762    /// `None` here means "no direct path confirmed within the timeout" (the peer may still be
1763    /// reachable via DERP). Unlike [`ping`](Device::ping) (an ICMP echo over the netstack), this
1764    /// measures the disco/underlay path the data plane uses for direct connections.
1765    pub async fn ping_disco(
1766        &self,
1767        dst: IpAddr,
1768        timeout: Duration,
1769    ) -> Result<Option<(SocketAddr, Duration)>, Error> {
1770        self.runtime
1771            .ping_disco(dst, timeout)
1772            .await
1773            .map_err(Into::into)
1774    }
1775
1776    /// Obtain a TLS certificate for a node's MagicDNS `name` (like `tsnet`'s `GetCertificate`).
1777    ///
1778    /// **Fail-closed without the `acme` feature.** By default this fork has no client-side ACME
1779    /// engine wired in, so this returns [`ts_control::CertError::Unimplemented`] (after a
1780    /// tailnet-name check) — it NEVER self-signs and NEVER returns a placeholder certificate
1781    /// ([`ts_control::MISSING_CERT_RPC`] names what is missing).
1782    ///
1783    /// **With the `acme` feature** this instead drives the client-side ACME DNS-01 engine to issue a
1784    /// real Let's Encrypt certificate for `name`, publishing the challenge TXT via the node's
1785    /// `POST /machine/set-dns` RPC (routed through the control runner). SaaS-only: a self-hosted
1786    /// control plane may 501 on set-dns, surfaced as [`ts_control::CertError::Acme`].
1787    #[cfg(not(feature = "acme"))]
1788    pub async fn get_certificate(&self, name: &str) -> Result<CertifiedKey, ts_control::CertError> {
1789        ts_control::get_certificate(name).await
1790    }
1791
1792    /// See the no-`acme` variant for the contract; with `acme` this issues a real cert via the
1793    /// runtime's ACME engine (`Device → Runtime → ControlRunner → issue_certificate_via_setdns`).
1794    #[cfg(feature = "acme")]
1795    pub async fn get_certificate(&self, name: &str) -> Result<CertifiedKey, ts_control::CertError> {
1796        self.runtime.get_certificate(name.to_string()).await
1797    }
1798
1799    /// Issue a real Let's Encrypt certificate for a node's MagicDNS `name` and return the **PEM
1800    /// pair** `(cert_chain_pem, key_pem)` — the analog of Go's `LocalClient.CertPairWithValidity`,
1801    /// for writing the daemon's on-disk `.crt` + `.key` (`tnet cert`). **`acme` feature only.**
1802    ///
1803    /// This drives the same client-side ACME DNS-01 issuance as [`Device::get_certificate`] (one
1804    /// order, the challenge TXT published via the node's `POST /machine/set-dns` RPC, routed through
1805    /// the runtime → control runner); it differs only in returning the raw leaf+chain PEM and the
1806    /// leaf private-key PEM instead of the opaque [`CertifiedKey`]. The second tuple element is
1807    /// **secret key material**: it is never logged anywhere on this path — persist it to a `0600`
1808    /// file and never trace it.
1809    ///
1810    /// **`min_validity` (honest "always fresh").** Go's `CertPairWithValidity` reuses a cached cert
1811    /// when it has at least `min_validity` of its lifetime remaining, re-issuing otherwise. This
1812    /// fork keeps **no cert cache** — every call issues fresh — so `min_validity` is accepted for
1813    /// signature compatibility but does not alter behavior: a freshly issued (full-lifetime) cert
1814    /// satisfies any `min_validity`. A reuse cache is separate future work; this does NOT fake one.
1815    ///
1816    /// Fail-closed: returns a [`ts_control::CertError`] (never a self-signed or partial pair) on any
1817    /// ACME/HTTP failure. SaaS-only: a self-hosted control plane may 501 on set-dns, surfaced as
1818    /// [`ts_control::CertError::Acme`].
1819    #[cfg(feature = "acme")]
1820    pub async fn cert_pair(
1821        &self,
1822        name: &str,
1823        min_validity: Option<Duration>,
1824    ) -> Result<(String, String), ts_control::CertError> {
1825        self.runtime.cert_pair(name.to_string(), min_validity).await
1826    }
1827
1828    /// Build a [`TlsAcceptor`] terminating TLS for `cfg.name` on the overlay (like `tsnet`'s
1829    /// `ListenTLS`).
1830    ///
1831    /// Obtains the certificate via [`Device::get_certificate`] — so with the `acme` feature this
1832    /// issues a real Let's Encrypt cert (when the control plane answers `set-dns`), and without it
1833    /// (or when issuance is unavailable) it surfaces the same fail-closed
1834    /// [`ts_control::CertError`] rather than ever serving a self-signed cert or downgrading to
1835    /// plaintext. Terminate accepted overlay streams with [`ts_control::accept_tls`].
1836    pub async fn listen_tls(
1837        &self,
1838        cfg: &ts_control::ServeConfig,
1839    ) -> Result<TlsAcceptor, ts_control::CertError> {
1840        // Route through Device::get_certificate (the acme-aware issuance path) rather than
1841        // ts_control::listen_tls, which only knows the non-acme stub. Validate the serve config
1842        // first (same fail-closed checks ts_control::listen_tls applies), then assemble the acceptor.
1843        cfg.validate()?;
1844        let cert = self.get_certificate(&cfg.name).await?;
1845        ts_control::tls_acceptor(cert)
1846    }
1847
1848    /// The currently-stored Serve config (like `tsnet`'s `GetServeConfig`).
1849    ///
1850    /// Returns the config last passed to [`Device::set_serve_config`], or an empty
1851    /// [`ts_control::ServeState`] (no ports) if none was ever set. Pure read — does not touch the
1852    /// network.
1853    pub fn get_serve_config(&self) -> ts_control::ServeState {
1854        match &*self.serve.lock().unwrap_or_else(|e| e.into_inner()) {
1855            Some(mgr) => mgr.get(),
1856            None => ts_control::ServeState::default(),
1857        }
1858    }
1859
1860    /// Replace this node's Serve config and (re)bind its tailnet ports (like `tsnet`'s
1861    /// `SetServeConfig`, REPLACE semantics).
1862    ///
1863    /// `state` becomes the **whole** config (full-replace reconcile: every previously-bound serve
1864    /// port's accept loop is torn down and the new config's ports are bound from scratch). For each
1865    /// configured port the manager binds an overlay listener on this node's tailnet IPv4 and
1866    /// dispatches per [`ts_control::ServeTarget`]:
1867    /// - [`Accept`](ts_control::ServeTarget::Accept) — the TLS-terminated stream is handed back over
1868    ///   the returned [`ServeAcceptedReceiver`](ts_runtime::serve::ServeAcceptedReceiver) (the
1869    ///   in-process stand-in for `ListenTLS`'s `net.Listener`).
1870    /// - [`Proxy`](ts_control::ServeTarget::Proxy) — reverse-proxy the decrypted stream to a local
1871    ///   host backend.
1872    /// - [`Text`](ts_control::ServeTarget::Text) — write a fixed body and close.
1873    /// - [`TcpForward`](ts_control::ServeTarget::TcpForward) — forward the **raw** (non-TLS) stream
1874    ///   to a local host backend.
1875    ///
1876    /// **Fail-closed.** `state.validate()` runs first. Every TLS-terminating port's acceptor is
1877    /// obtained up-front via [`Device::listen_tls`] (the ACME-aware cert path); if any cert cannot be
1878    /// issued the whole call fails with that [`ts_control::CertError`] and **nothing is bound** — a
1879    /// TLS port never downgrades to plaintext.
1880    ///
1881    /// **Anti-leak.** Listeners bind the overlay netstack only (never a host socket). The
1882    /// `Proxy`/`TcpForward` backend dial is a local host socket to the embedder's own backend (like
1883    /// Go's reverse-proxy to `127.0.0.1`), intentionally NOT routed through the exit-egress
1884    /// forwarder. A backend dial failure drops that connection; it never falls back.
1885    ///
1886    /// Returns an error in TUN transport mode (there is no application netstack to bind on). The
1887    /// previous config's accept loops (and any earlier `ServeAcceptedReceiver`) stop when this
1888    /// returns; the new receiver delivers every `Accept`-port connection.
1889    pub async fn set_serve_config(
1890        &self,
1891        state: ts_control::ServeState,
1892    ) -> Result<ts_runtime::serve::ServeAcceptedReceiver, Error> {
1893        state
1894            .validate()
1895            .map_err(|_| Error::Internal(InternalErrorKind::BadRequest))?;
1896
1897        // Fail-closed: build every TLS-terminating port's acceptor up-front via the ACME-aware cert
1898        // path. If any cert can't be issued, return before binding anything (no plaintext downgrade).
1899        let mut resolved = std::collections::BTreeMap::new();
1900        for (port, target) in &state.ports {
1901            let acceptor = if target.terminates_tls() {
1902                let cfg = ts_control::ServeConfig {
1903                    name: state.name.clone(),
1904                    port: *port,
1905                    target: target.clone(),
1906                };
1907                Some(self.listen_tls(&cfg).await.map_err(|_| {
1908                    // Cert issuance is fail-closed in this fork; surface as a request error rather
1909                    // than ever binding a plaintext TLS port.
1910                    Error::Internal(InternalErrorKind::BadRequest)
1911                })?)
1912            } else {
1913                None
1914            };
1915            resolved.insert(
1916                *port,
1917                ts_runtime::serve::ResolvedPort {
1918                    target: target.clone(),
1919                    acceptor,
1920                },
1921            );
1922        }
1923
1924        // The manager binds the OVERLAY netstack on this node's own tailnet IPv4.
1925        let self_ipv4 = self.ipv4_addr().await?;
1926        let channel = self.channel()?.clone();
1927
1928        let mut slot = self.serve.lock().unwrap_or_else(|e| e.into_inner());
1929        let mgr =
1930            slot.get_or_insert_with(|| ts_runtime::serve::ServeManager::new(channel, self_ipv4));
1931        Ok(mgr.set(state, resolved))
1932    }
1933
1934    /// Expose a tailnet TLS service to the public internet via Tailscale Funnel (like `tsnet`'s
1935    /// `ListenFunnel`), returning a [`FunnelAcceptedReceiver`](ts_runtime::funnel::FunnelAcceptedReceiver)
1936    /// that delivers each TLS-terminated public connection.
1937    ///
1938    /// **Two fail-closed gates, then the live ingress listener.** First the node-attribute gate is
1939    /// fully enforced from this node's own capability map (mirroring Go `ipn.NodeCanFunnel` +
1940    /// `ipn.CheckFunnelPort`): the tailnet admin must have enabled HTTPS and granted the `funnel`
1941    /// node attribute, and `cfg.port` must be in the set the `funnel-ports` capability allows —
1942    /// otherwise this returns [`ts_control::FunnelError::NotAllowed`] /
1943    /// [`ts_control::FunnelError::PortNotAllowed`] before touching any cert or network. Then the
1944    /// node's `*.ts.net` certificate is obtained via the ACME-aware [`Device::get_certificate`] (the
1945    /// Funnel hostname *is* the node's MagicDNS name, so its DNS-01 cert matches); fail-closed on
1946    /// [`ts_control::FunnelError::Cert`] — no self-signed or plaintext fallback.
1947    ///
1948    /// On success a [`FunnelManager`](ts_runtime::funnel::FunnelManager) is registered: its ingress
1949    /// sink is installed into the runtime's peerAPI `/v0/ingress` slot (making that route live without
1950    /// restarting the peerAPI server), and the `HostInfo.IngressEnabled` map-request signal is set so
1951    /// control routes Funnel traffic to this node. Public Funnel bytes arrive as a relay POST to
1952    /// `/v0/ingress`, are membership-gated + `101`-hijacked into a raw stream, TLS-terminated by the
1953    /// manager, and delivered over the returned receiver.
1954    ///
1955    /// **Where the relay comes from.** The public ingress **relay + DNS mapping** that feed
1956    /// `/v0/ingress` are Tailscale infrastructure ([`ts_control::MISSING_FUNNEL_RELAY`]), provisioned
1957    /// automatically against real Tailscale SaaS with a Funnel-enabled ACL; against a self-hosted
1958    /// control plane no relay exists, so the listener is correct but never fed.
1959    ///
1960    /// Anti-leak: Funnel TLS terminates only on the overlay netstack (the hijacked ingress stream
1961    /// arrives on the overlay peerAPI listener), never a host socket; there is no self-signed or
1962    /// plaintext fallback. A new `listen_funnel` replaces the previous manager (its pump + sink tear
1963    /// down); dropping the `Device` tears it down too.
1964    pub async fn listen_funnel(
1965        &self,
1966        cfg: &ts_control::ServeConfig,
1967        opts: ts_control::FunnelOptions,
1968    ) -> Result<ts_runtime::funnel::FunnelAcceptedReceiver, ts_control::FunnelError> {
1969        // Gate 1 (fail-closed, no network): node-attribute + funnel-port access from our cap map.
1970        let me = self
1971            .self_node()
1972            .await
1973            .map_err(|_| ts_control::FunnelError::NotAllowed)?;
1974        cfg.validate()?;
1975        ts_control::funnel_access(&me, cfg.port)?;
1976
1977        // Gate 2 (fail-closed): obtain the node's `*.ts.net` cert via the ACME-aware path and build
1978        // the TLS acceptor. A cert failure surfaces as FunnelError::Cert — never a plaintext listener.
1979        let cert = self
1980            .get_certificate(&cfg.name)
1981            .await
1982            .map_err(ts_control::FunnelError::Cert)?;
1983        let acceptor = ts_control::tls_acceptor(cert).map_err(ts_control::FunnelError::Cert)?;
1984
1985        // `opts.funnel_only` (reject tailnet-internal connections) is accepted for surface stability;
1986        // the ingress data path only ever carries relay-delivered public traffic, so there is no
1987        // tailnet-internal leg on this listener to reject. Documented as a no-op here for now.
1988        let _ = opts;
1989
1990        // Build the funnel manager + its ingress sink + the hand-back receiver, install the sink into
1991        // the runtime's shared peerAPI `/v0/ingress` slot (making the route live), and flip the
1992        // IngressEnabled map signal. Hold the manager on the device so its pump/sink live as long as
1993        // the listener; replacing a prior manager tears the old one down on drop at end of scope.
1994        let (manager, sink, receiver) = ts_runtime::funnel::FunnelManager::new(acceptor);
1995        {
1996            let slot = self.runtime.funnel_ingress_slot();
1997            *slot.lock().unwrap_or_else(|e| e.into_inner()) = Some(sink);
1998        }
1999        self.runtime
2000            .ingress_active_flag()
2001            .store(true, std::sync::atomic::Ordering::Relaxed);
2002
2003        let old = {
2004            let mut held = self.funnel.lock().unwrap_or_else(|e| e.into_inner());
2005            held.replace(manager)
2006        };
2007        drop(old);
2008
2009        Ok(receiver)
2010    }
2011
2012    /// Host a Tailscale **VIP service** (`svc:<label>`) by binding an overlay listener on the
2013    /// service's control-assigned virtual IP (like `tsnet`'s `ListenService`).
2014    ///
2015    /// **Fail-closed.** Mirrors Go `tsnet.Server.ListenService`'s preconditions, enforced from this
2016    /// node's own netmap state ([`ts_control::resolve_service_listen`]): the `name` must be a valid
2017    /// `svc:<dns-label>`, this node must be **tagged** (Go `ErrUntaggedServiceHost`), and control
2018    /// must have assigned the service a VIP address on this node (delivered via the `service-host`
2019    /// node-capability — see [`ts_control::Node::service_addresses`]). Any unmet precondition
2020    /// returns a typed [`ts_control::ServiceError`] before binding anything.
2021    ///
2022    /// When all hold, this binds a [`tcp_listen`][Device::tcp_listen] on the service VIP and the
2023    /// configured `mode` port over the **overlay netstack** (never a host socket) and returns the
2024    /// listener. The netstack already accepts packets for control-assigned VIPs (they are injected
2025    /// alongside the node's own tailnet address), so the listener is reachable by tailnet peers.
2026    ///
2027    /// The `Tun`/L3 service mode is unsupported (a TODO in upstream tsnet); only TCP/HTTP modes
2028    /// (which bind the same VIP:port at the listen layer) are offered. Returns an error in TUN
2029    /// transport mode (there is no application netstack to bind on).
2030    pub async fn listen_service(
2031        &self,
2032        name: &str,
2033        mode: ts_control::ServiceMode,
2034    ) -> Result<netstack::TcpListener, ts_control::ServiceError> {
2035        let me = self
2036            .self_node()
2037            .await
2038            .map_err(|e| ts_control::ServiceError::Listen(e.to_string()))?;
2039        let listen_addr = ts_control::resolve_service_listen(&me, name, mode, self.enable_ipv6)?;
2040        self.tcp_listen(listen_addr)
2041            .await
2042            .map_err(|e| ts_control::ServiceError::Listen(e.to_string()))
2043    }
2044
2045    /// Attempt to gracefully shut down this device's runtime.
2046    ///
2047    /// Reports whether the device was fully shut down before the timeout. It is still shut
2048    /// down if it timed out, just more violently and with potential resource leaks.
2049    ///
2050    /// If `timeout` is `None`, then shutdown will never time-out.
2051    pub async fn shutdown(self, timeout: Option<Duration>) -> bool {
2052        self.runtime.graceful_shutdown(timeout).await
2053    }
2054}
2055
2056/// Command-channel-driven userspace network stack.
2057///
2058/// This is an opinionated wrapper around [smoltcp](https://docs.rs/smoltcp) that provides an
2059/// easier-to-integrate, more-portable API.
2060pub mod netstack {
2061    #[doc(inline)]
2062    pub use ts_netstack_smoltcp::netcore::Error;
2063    #[doc(inline)]
2064    pub use ts_netstack_smoltcp::netcore::InternalErrorKind;
2065    #[doc(inline)]
2066    pub use ts_netstack_smoltcp::netsock::{TcpListener, TcpStream, UdpSocket};
2067}
2068
2069/// Geneve (RFC 8926) framing for Tailscale **peer-relay** traffic. A peer that advertises
2070/// [`NodeInfo::is_peer_relay`] runs a UDP relay server; relayed disco + WireGuard frames are
2071/// Geneve-encapsulated with a VNI. This module exposes the header codec.
2072///
2073/// The relay *client* path is implemented: a peer's `CallMeMaybeVia` starts a bind handshake with
2074/// the relay server it names, and once that completes the relay endpoint carries WireGuard data
2075/// instead of the peer falling back to DERP (`ts_magicsock`'s relay module). This node does not
2076/// *serve* as a relay, and does not request endpoint allocations of its own — a relay-capable peer
2077/// allocates on our behalf and tells us with a `CallMeMaybeVia`.
2078pub mod geneve {
2079    #[doc(inline)]
2080    pub use ts_packet::geneve::{
2081        GENEVE_FIXED_HEADER_LEN, GENEVE_PROTOCOL_DISCO, GENEVE_PROTOCOL_WIREGUARD, GeneveError,
2082        GeneveHeader,
2083    };
2084}
2085
2086/// Tailnet Lock (TKA) verification: the [`tka::Authority`] checks a peer's node-key signature
2087/// against the trusted-key state, mirroring Go's `tka` package. Pair with [`Device::tka_status`]
2088/// (the control-pushed head/disablement signal).
2089pub mod tka {
2090    #[doc(inline)]
2091    pub use ts_tka::{
2092        AumHash, AumKind, Authority, Key, KeyKind, NodeKeySignature, SigKind, State, TkaError,
2093        aum_hash,
2094    };
2095}
2096
2097/// Tailscale cryptographic key types.
2098pub mod keys {
2099    #[doc(inline)]
2100    pub use ts_keys::{
2101        DiscoKeyPair, DiscoPrivateKey, DiscoPublicKey, MachineKeyPair, MachinePrivateKey,
2102        MachinePublicKey, NetworkLockKeyPair, NetworkLockPrivateKey, NetworkLockPublicKey,
2103        NodeKeyPair, NodePrivateKey, NodePublicKey, NodeState, PersistState,
2104    };
2105}
2106
2107const ENV_MAGIC_VAR: &str = "TS_RS_EXPERIMENT";
2108const ENV_MAGIC_VALUE: &str = "this_is_unstable_software";
2109
2110fn check_magic_env() -> Result<(), Error> {
2111    if std::env::var(ENV_MAGIC_VAR).as_deref() != Ok(ENV_MAGIC_VALUE) {
2112        let warning = format!(
2113            "
2114check failed: set {ENV_MAGIC_VAR}={ENV_MAGIC_VALUE} to acknowledge that tailscale-rs is early-days
2115experimental software containing bugs, unvalidated cryptography, and no stability or compatibility
2116guarantees.
2117            "
2118        );
2119
2120        eprintln!("{}", warning.trim());
2121
2122        return Err(Error::UnstableEnvVar);
2123    };
2124
2125    Ok(())
2126}
2127
2128#[cfg(test)]
2129mod tests {
2130    use secrecy::ExposeSecret as _;
2131
2132    use super::*;
2133
2134    // `Device::new`/`new_with_secret` cannot be unit-tested end-to-end without a live control
2135    // server (registration). The only behavioral difference `new_with_secret` introduces over `new`
2136    // is exposing the `SecretString` to a plain `String` on the last inch; everything after is the
2137    // shared `new` path. So we assert that equivalence at the auth-key-resolution level: the secret
2138    // path must resolve to the exact same key the plain path feeds into `resolve_auth_key`.
2139    const SAMPLE_KEY: &str = "tskey-auth-koCgSLP5R811CNTRL-EXAMPLEEXAMPLEEXAMPLEEXAMPLE";
2140
2141    // The mapping `new_with_secret` applies (`Option<SecretString>` -> `Option<String>`) must be a
2142    // byte-for-byte round-trip, so the spawn arg is identical to a direct `new(config, Some(..))`.
2143    #[test]
2144    fn secret_exposes_to_identical_string() {
2145        let plain = Some(SAMPLE_KEY.to_string());
2146        let from_secret =
2147            Some(SecretString::from(SAMPLE_KEY)).map(|s| s.expose_secret().to_string());
2148        assert_eq!(from_secret, plain);
2149
2150        // `None` must pass through unchanged (so it falls back to `config.auth_key` exactly as `new`).
2151        let none_secret: Option<SecretString> = None;
2152        assert_eq!(
2153            none_secret.map(|s| s.expose_secret().to_string()),
2154            None::<String>
2155        );
2156    }
2157
2158    /// A minimal peer record with a usable IPv4 peerAPI — the shape [`Device::peer_by_name`] and
2159    /// [`Device::file_targets`] hand a caller, and which the caller may then hold for a while.
2160    fn peer_snapshot() -> NodeInfo {
2161        NodeInfo {
2162            id: 1,
2163            stable_id: ts_control::StableNodeId("nPeEr1".to_string()),
2164            hostname: "peer".to_string(),
2165            user_id: 0,
2166            tailnet: Some("ts.net".to_string()),
2167            tags: Vec::new(),
2168            addresses: vec!["100.64.0.9/32".parse().unwrap()],
2169            tailnet_address: ts_control::TailnetAddress {
2170                ipv4: "100.64.0.9/32".parse().unwrap(),
2171                ipv6: "fd7a:115c:a1e0::9/128".parse().unwrap(),
2172            },
2173            node_key: [1u8; 32].into(),
2174            node_key_expiry: None,
2175            expired: false,
2176            online: Some(true),
2177            last_seen: None,
2178            key_signature: Vec::new(),
2179            machine_key: None,
2180            disco_key: None,
2181            unsigned_peer_api_only: false,
2182            accepted_routes: Vec::new(),
2183            underlay_addresses: Vec::new(),
2184            ssh_host_keys: Vec::new(),
2185            derp_region: None,
2186            cap: Default::default(),
2187            cap_map: Default::default(),
2188            peerapi_port: Some(8080),
2189            peerapi_dns_proxy: false,
2190            is_wireguard_only: false,
2191            exit_node_dns_resolvers: Vec::new(),
2192            peer_relay: false,
2193            service_vips: Default::default(),
2194        }
2195    }
2196
2197    // The stale-snapshot case `send_file` re-reads the peer for. A caller can hold a `NodeInfo` from
2198    // before the peer's node key expired — the peer tracker's own timer flags a peer with no netmap
2199    // in between — and that snapshot still says `expired: false` and still yields a peerAPI address.
2200    // Deciding on it dials a peer whose key this node has already broken and reports the dial
2201    // failure (`Error::Timeout`) instead of the reason.
2202    #[test]
2203    fn taildrop_dst_refuses_a_peer_the_tracker_has_since_expired() {
2204        let snapshot = peer_snapshot();
2205
2206        // What the snapshot alone says — i.e. what the send used to be decided on.
2207        assert_eq!(
2208            taildrop_dst(&snapshot, None),
2209            Ok("100.64.0.9:8080".parse().unwrap()),
2210            "the retained snapshot looks perfectly sendable"
2211        );
2212
2213        // What the peer tracker says now.
2214        let mut current = peer_snapshot();
2215        current.expired = true;
2216        assert_eq!(
2217            taildrop_dst(&snapshot, Some(&current)),
2218            Err(Error::PeerKeyExpired),
2219            "the current record decides, and it names the reason"
2220        );
2221    }
2222
2223    // The destination follows the current record too, not just the refusal: a peer that moved its
2224    // peerAPI port (or its tailnet address) since the snapshot was taken must be dialed where it is
2225    // now.
2226    #[test]
2227    fn taildrop_dst_uses_the_current_records_address() {
2228        let snapshot = peer_snapshot();
2229        let mut current = peer_snapshot();
2230        current.peerapi_port = Some(9099);
2231
2232        assert_eq!(
2233            taildrop_dst(&snapshot, Some(&current)),
2234            Ok("100.64.0.9:9099".parse().unwrap())
2235        );
2236    }
2237
2238    // No current record (the peer left the tailnet, or no netmap has arrived) leaves the snapshot as
2239    // the only thing to go on — the pre-existing behaviour, still bounded by the CGNAT assertion.
2240    #[test]
2241    fn taildrop_dst_falls_back_to_the_snapshot() {
2242        let snapshot = peer_snapshot();
2243
2244        assert_eq!(
2245            taildrop_dst(&snapshot, None),
2246            Ok("100.64.0.9:8080".parse().unwrap())
2247        );
2248
2249        let mut off_tailnet = peer_snapshot();
2250        off_tailnet.tailnet_address.ipv4 = "192.0.2.9/32".parse().unwrap();
2251        assert_eq!(
2252            taildrop_dst(&off_tailnet, None),
2253            Err(Error::Internal(InternalErrorKind::BadRequest)),
2254            "a record pointing off the tailnet is refused rather than dialed"
2255        );
2256    }
2257
2258    // End-to-end equivalence at the resolve layer: feeding the exposed secret through
2259    // `resolve_auth_key` yields the same `Option<String>` as feeding the plain string — i.e. both
2260    // constructors reach the same spawn argument, without registering against a control server.
2261    #[tokio::test]
2262    async fn new_with_secret_resolves_same_as_new() {
2263        let config = Config::default();
2264
2265        let via_plain = resolve_auth_key(&config, Some(SAMPLE_KEY.to_string()))
2266            .await
2267            .expect("plain auth key resolves");
2268
2269        let exposed = Some(SecretString::from(SAMPLE_KEY)).map(|s| s.expose_secret().to_string());
2270        let via_secret = resolve_auth_key(&config, exposed)
2271            .await
2272            .expect("secret-derived auth key resolves");
2273
2274        assert_eq!(via_plain, via_secret);
2275        // Without the `identity-federation` feature `resolve_auth_key` is a pass-through, so the
2276        // resolved key is the input verbatim; assert that too to pin the default-build behavior.
2277        #[cfg(not(feature = "identity-federation"))]
2278        assert_eq!(via_secret, Some(SAMPLE_KEY.to_string()));
2279    }
2280}